A custom versioning system in plain PHP
Database migrations are versioned, traceable schema changes that every team runs without manual ALTER TABLE commands over SSH connections. A custom built migration system made of a migrations table, up and down methods and a lean CLI runner covers the core requirements without introducing Doctrine Migrations or Laravel as a dependency.
Table of contents
- 1. Why database migrations are necessary at all
- 2. The migrations table: the foundation of every migration system
- 3. Designing a migration class with up() and down()
- 4. The migration runner: ordering and execution
- 5. Rollback: implementing down() safely and traceably
- 6. Transactional migrations and their limits
- 7. Separating seed data from structural migrations
- 8. Running migrations automatically in CI/CD pipelines
- 9. Migrations compared: custom build, Doctrine, Phinx
- 10. Summary
- 11. FAQ
1. Why database migrations are necessary at all
Database migrations solve a problem every growing project runs into eventually: the database schema has to evolve alongside the code, without anyone manually typing ALTER TABLE commands on the production database. Without database migrations, a situation quickly arises where nobody knows exactly which column exists in which environment. A colleague adds a column locally, forgets to document it, and three weeks later the deployment to staging breaks because the same column is missing there.
Database migrations solve this problem through versioning: every schema change is stored as its own, numbered file in the codebase, tracked with version control like Git, and executed by a script in exactly the same order on every environment. The result is a schema whose current state can be reproduced from the code at any time, regardless of whether the database runs locally, on staging or in production.
Well known frameworks like Symfony or Laravel ship ready made migration systems, but the underlying principle is simple enough to understand and build yourself without a framework. For smaller projects or plain PHP applications without a full framework, a custom, lean migration system is often the better choice than a heavyweight dependency for schema management alone.
2. The migrations table: the foundation of every migration system
Every migration system needs its own table in the database that records which database migrations have already been executed. This table, usually called migrations, stores at minimum the filename or a version number plus a timestamp of execution. When the migration runner starts, it first checks whether this table exists, and if not, creates it automatically, so the system works against a completely empty database.
The migrations table is the state store that decides which database migrations still need to run at the next execution. Without this table, someone would have to manually track which schema state has already been reached at every deployment, which quickly leads to inconsistencies in teams with multiple developers.
<?php
declare(strict_types=1);
final class MigrationRepository
{
public function __construct(private readonly PDO $pdo)
{
}
/** Creates the tracking table if it does not exist yet. */
public function ensureTableExists(): void
{
$this->pdo->exec(<<<SQL
CREATE TABLE IF NOT EXISTS migrations (
id INT AUTO_INCREMENT PRIMARY KEY,
migration VARCHAR(255) NOT NULL UNIQUE,
executed_at DATETIME NOT NULL
)
SQL);
}
/** @return array<int, string> Names of already executed migrations. */
public function getExecuted(): array
{
$statement = $this->pdo->query('SELECT migration FROM migrations ORDER BY id');
return $statement->fetchAll(PDO::FETCH_COLUMN);
}
public function markExecuted(string $migration): void
{
$statement = $this->pdo->prepare(
'INSERT INTO migrations (migration, executed_at) VALUES (?, NOW())'
);
$statement->execute([$migration]);
}
public function markRolledBack(string $migration): void
{
$statement = $this->pdo->prepare('DELETE FROM migrations WHERE migration = ?');
$statement->execute([$migration]);
}
}
3. Designing a migration class with up() and down()
Every single schema change is modeled as its own class with two methods: up() applies the change, down() reverts it. This symmetry is the core of every migration system, because it allows a schema to move not only forward but also backward to an earlier state. The filename of every database migration usually starts with a timestamp, so alphabetical sorting automatically matches chronological order.
An interface for all migration classes enforces this structure and lets the runner treat every migration the same way, regardless of its concrete content. The actual SQL code inside up() and down() is deliberately kept simple: plain, readable SQL instead of an abstracted schema builder, which considerably eases debugging when problems occur.
<?php
declare(strict_types=1);
interface Migration
{
public function up(PDO $pdo): void;
public function down(PDO $pdo): void;
}
/** Migration: 20260731120000_create_orders_table.php */
final class CreateOrdersTable implements Migration
{
public function up(PDO $pdo): void
{
$pdo->exec(<<<SQL
CREATE TABLE orders (
id INT AUTO_INCREMENT PRIMARY KEY,
customer_id INT NOT NULL,
total DECIMAL(10,2) NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'pending',
created_at DATETIME NOT NULL,
INDEX idx_customer (customer_id)
)
SQL);
}
public function down(PDO $pdo): void
{
$pdo->exec('DROP TABLE IF EXISTS orders');
}
}
4. The migration runner: ordering and execution
The migration runner is the link between the migration files on the filesystem and the migrations table in the database. When executed, it reads all available migration classes from a directory, compares them against the already executed entries from the migrations table, and only runs the difference, in ascending order by timestamp. This diffing is the central mechanism that makes database migrations idempotent: running the runner again on an already current database changes nothing.
Errors during execution must lead to an immediate abort, before later database migrations build on top of an inconsistent intermediate state. A good runner records each successfully executed migration in the migrations table individually, right after it runs rather than only at the end of the whole batch, so that an abort halfway through does not lose progress.
<?php
declare(strict_types=1);
final class MigrationRunner
{
public function __construct(
private readonly PDO $pdo,
private readonly MigrationRepository $repository,
private readonly string $migrationsPath,
) {
}
public function run(): void
{
$this->repository->ensureTableExists();
$executed = $this->repository->getExecuted();
$files = glob($this->migrationsPath . '/*.php');
sort($files);
foreach ($files as $file) {
$name = basename($file, '.php');
if (in_array($name, $executed, true)) {
continue;
}
require_once $file;
$className = $this->classNameFromFile($name);
/** @var Migration $migration */
$migration = new $className();
echo "Running migration: {$name}\n";
$migration->up($this->pdo);
$this->repository->markExecuted($name);
}
}
private function classNameFromFile(string $name): string
{
// Convention: 20260731120000_create_orders_table -> CreateOrdersTable
$parts = explode('_', $name);
array_shift($parts);
return implode('', array_map('ucfirst', $parts));
}
}
5. Rollback: implementing down() safely and traceably
A rollback reverts the most recently executed database migrations in reverse order, by calling the down() methods and removing the corresponding entries from the migrations table. This mechanism is particularly valuable right after a failed deployment, when a new migration has caused problems and the previous state needs to be restored quickly.
It is important that down() methods are written carefully from the start, not only once a rollback is actually needed. In practice, down() is rarely exercised until the real emergency hits, and exactly then it must not fail. A DROP COLUMN inside down() is also destructive: data in that column is lost, which can mean data loss during rollbacks in production environments and must be communicated clearly beforehand.
<?php
declare(strict_types=1);
public function rollback(int $steps = 1): void
{
$executed = array_reverse($this->repository->getExecuted());
$toRollback = array_slice($executed, 0, $steps);
foreach ($toRollback as $name) {
$file = $this->migrationsPath . '/' . $name . '.php';
require_once $file;
$className = $this->classNameFromFile($name);
/** @var Migration $migration */
$migration = new $className();
echo "Rolling back: {$name}\n";
$migration->down($this->pdo);
$this->repository->markRolledBack($name);
}
}
6. Transactional migrations and their limits
Ideally, every single migration runs inside a database transaction, so an error midway through execution leaves the entire schema unchanged instead of leaving a half finished intermediate state. In PHP with PDO, this means starting beginTransaction() before calling up() and calling either commit() or rollBack() depending on the result. For simple database migrations with only DML or single DDL statements, this pattern works reliably.
An important restriction concerns MySQL: DDL statements like CREATE TABLE or ALTER TABLE trigger implicit commits there, so a transaction wrapped around multiple DDL statements in the same migration does not behave as expected. PostgreSQL fully supports transactional DDL, MySQL does not. Anyone writing database migrations for both systems needs to know this restriction and keep migrations small and atomic accordingly, instead of blindly relying on transactional protection.
7. Separating seed data from structural migrations
Structural database migrations, which create tables, columns and indexes, should be strictly separated from seed data that inserts test data or reference values such as country or status lists. The reason is repeatability: a structural migration must run exactly once, whereas a seed script should often be executable multiple times, for example to reset a local development database without rebuilding the schema.
A dedicated directory for seeder classes, separate from the migrations directory, makes this distinction visible in the code. Seeder classes ideally use INSERT ... ON DUPLICATE KEY UPDATE or a preceding TRUNCATE statement so repeated execution does not create duplicate rows, while genuine database migrations stay strictly one time via the migrations table.
8. Running migrations automatically in CI/CD pipelines
In a CI/CD pipeline, database migrations should run as their own, explicit step before the actual application deployment, never implicitly on the first page load of a new version. The migration runner is invoked through a CLI script whose exit code determines success or failure, so the pipeline automatically stops the further rollout if a migration fails.
For production deployments with multiple application servers, an additional lock is necessary so two servers do not run the same database migrations at the same time. A simple lock entry in the migrations table or a database advisory lock prevents race conditions where two processes start the same migration in parallel and get in each other's way.
9. Migrations compared: custom build, Doctrine, Phinx
A custom built system for database migrations makes sense when a project does not need a full ORM and the requirements for schema management stay manageable. Established tools like Doctrine Migrations or the standalone Phinx, on the other hand, offer considerably more convenience, for example automatic generation of migrations from entity changes or support for several database drivers at once.
| Criterion | Custom migrations | Doctrine Migrations | Phinx |
|---|---|---|---|
| Dependencies | None | Doctrine ORM/DBAL required | Standalone Composer package |
| Diff generation | Not available | From entity metadata | Manual only |
| Rollback support | Self implemented | Built in | Built in |
| Control over SQL | Full | Partly abstracted | Largely direct |
| Best fit for | Small PHP projects without an ORM | Projects using Doctrine ORM | Framework independent projects |
Anyone already using Doctrine in a project benefits from the direct integration of Doctrine Migrations. For plain PHP projects without an ORM, a lean custom build or the standalone Phinx remain the better fit, because neither requires an additional ORM dependency, and both treat database migrations as a standalone, clearly scoped tool.
10. Summary
Database migrations solve the fundamental problem of applying schema changes in a traceable, versioned and automated way across multiple environments. The most important building blocks of a custom migration system are a migrations table as state store, migration classes with symmetric up() and down() methods, a runner that only executes the difference, and a clear separation between structural migrations and seed data.
In CI/CD pipelines, database migrations belong as an explicit, monitored step before the actual deployment, with locking mechanisms against parallel execution when multiple application servers are involved. Anyone who knows the MySQL specific limits of transactional DDL statements and maintains down() methods carefully from the start ends up with little code and a robust, framework independent migration system that can be replaced by Doctrine Migrations or Phinx at any time if needed.
Database Migrations Without a Framework — The essentials at a glance
Migrations table
Records executed migrations, created automatically on the first run.
up() & down()
Symmetric methods per migration, test down() carefully from the start.
Transactions
MySQL commits DDL implicitly, PostgreSQL fully supports transactional DDL.
CI/CD
Migrations as an explicit pipeline step with a lock against parallel execution.