Migrations

Note

Not part of core. Install it separately:

composer require kinetis/migrations

It requires kinetis/framework, kinetis/persistence and kinetis/database-bridge, and installing it registers the migrate* commands on vendor/bin/kinetis (see CLI).

The commands always hold one PDO session for their advisory lock, whatever DB_DRIVER says. Install ext-pdo_mysql for DB_CONNECTION=mysql, or ext-pdo_pgsql for DB_CONNECTION=pgsql. An application that selects the native driver for request work still needs the matching PDO extension for migrations. Declare that extension in the application’s composer.json and install it in the runtime or migration image.

A thin runner for versioned schema changes: raw SQL up()/down() migrations, tracked in a kinetis_migrations table in each database, run through migrate* commands the package registers on vendor/bin/kinetis. No fluent DDL builder, no schema-diffing.

Writing a migration

Scaffold a migration:

vendor/bin/kinetis migrate:make "create orders table"
# Created migrations/20260810143000_create_orders_table.php

Each file in the migrations/ directory at your project root returns an anonymous class implementing Migration:

migrations/20260810143000_create_orders_table.php
<?php

declare(strict_types=1);

use Kinetis\Persistence\Contract\MysqlLink;
use Kinetis\Persistence\Contract\PostgresLink;
use Kinetis\Migrations\Migration;

return new class implements Migration
{
    public function up(MysqlLink|PostgresLink $db): void
    {
        $db->execute(<<<'SQL'
            CREATE TABLE orders (
                id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
                customer_id BIGINT UNSIGNED NOT NULL,
                status VARCHAR(20) NOT NULL DEFAULT 'pending',
                created_at DATETIME NOT NULL
            )
            SQL);
    }

    public function down(MysqlLink|PostgresLink $db): void
    {
        $db->execute('DROP TABLE orders');
    }
};

The timestamp prefix (YmdHis) keeps migrations in chronological order regardless of which branch created the file, and doubles as the name kinetis_migrations tracks it by. A multi-statement migration is multiple $db->execute() calls, not one string with semicolons.

Running migrations

vendor/bin/kinetis migrate           # runs every pending migration, in filename order
vendor/bin/kinetis migrate:rollback  # rolls back the migration applied most recently
vendor/bin/kinetis migrate:status    # lists every migration with its applied/pending state

The commands read the same DB_* keys as the application (Database) from the environment or .env:

DB_CONNECTION=mysql   # or "pgsql" — no default
DB_HOST=127.0.0.1
DB_NAME=app
DB_USER=app
DB_PASSWORD=secret

For example, a PostgreSQL image whose request path uses native ext-pgsql needs both clients because migrations use PDO:

RUN docker-php-ext-install pgsql pdo_pgsql

Its application manifest declares both platform requirements:

"require": {
    "ext-pdo_pgsql": "*",
    "ext-pgsql": "*"
}

They run without the application’s bootstrap, so they work in CI or an init container with nothing but environment variables, and a connection registered in bootstrap.php does not apply to them. DB_CONNECTION is required: guessing the wrong engine would run migrations against the wrong database with no warning at all.

migrate dispatches Kinetis\Migrations\Events\MigrationApplied once per migration it actually runs, in the order they ran; migrate:rollback dispatches Events\MigrationRolledBack when it undoes one. Each carries the migration’s name and the connection it ran on. Both are ordinary events — write a #[Listener] for whichever one you need (a deploy notification, for one). See Events for the full catalog.

Several databases

The files directly in migrations/ belong to the default connection. Each directory directly inside migrations/ belongs to the named connection of the same name, which reads its own DB_{NAME}_* keys (Configuration’s named-connection convention):

migrations/
├── 20260810143000_create_orders_table.php         # default: DB_*
└── reporting/
    └── 20260811090000_create_daily_totals.php     # reporting: DB_REPORTING_*
DB_REPORTING_CONNECTION=pgsql
DB_REPORTING_HOST=reporting.internal
DB_REPORTING_NAME=reporting
DB_REPORTING_USER=reporting
DB_REPORTING_PASSWORD=secret

A connection directory’s migrations run only on that connection’s database, and its kinetis_migrations table records only them. Discovery stops there: migrations/reporting/archive/ is neither a connection nor part of reporting.

Every directory directly inside migrations/ must be named as a connection: lowercase ASCII letters and digits, starting with a letter (^[a-z][a-z0-9]*$), the names kinetis/orm accepts for an entity’s connection. app is reserved, because its DB_APP_NAME key is also the default connection’s Postgres application name. A default directory is refused, because the default connection’s migrations are the files in migrations/ itself. A directory breaking these rules stops the command with an error naming it, rather than leaving a misspelt connection’s database unmigrated. --connection and MIGRATE_CONNECTION_NAME values follow the same rules, except that default selects the default connection. The commands check every name before they read any database configuration. A named connection without its DB_{NAME}_CONNECTION key fails naming that exact key, such as DB_REPORTING_CONNECTION.

Which connections a command covers

migrate and migrate:status cover every connection: the default first, then the connection directories in byte order of their names. With more than one connection, each connection’s lines follow a Connection: <name> line:

$ vendor/bin/kinetis migrate
Connection: default
Migrated: 20260810143000_create_orders_table
Connection: reporting
Nothing to migrate.

A project without connection directories has the default connection alone, and prints no Connection: line.

--connection=<name> narrows migrate, migrate:status and migrate:rollback to one connection. Without the flag, a non-empty MIGRATE_CONNECTION_NAME narrows them the same way. --connection=default selects the files in migrations/ itself. The plain command is how to cover every connection; there is no flag for it. A --connection with no value is refused.

vendor/bin/kinetis migrate --connection=reporting
vendor/bin/kinetis migrate:make "create daily totals" --connection=reporting
# Created migrations/reporting/20260811090000_create_daily_totals.php

migrate:make writes to migrations/ unless --connection=<name> names a connection; MIGRATE_CONNECTION_NAME does not change where it writes.

One database at a time

migrate runs the connections one after another, never in parallel. Each one runs on its own session, with its own ledger and advisory lock, and that session closes before the next connection’s opens.

With more than one connection, migrate first checks every one of them the way migrate:status does: its DB_* keys, its session, and its ledger against its directory. An unconfigured or unreachable connection, or one whose ledger fails the check, stops the run before any migration’s up(), and migrate prints that connection’s Connection: <name> line before the error. A check that passes prints nothing. Each connection checks again under its own lock when its turn comes.

A failure after that, such as a migration’s up() throwing or a lock timeout, stops the run at that connection. The connections before it stay migrated: no transaction spans databases, and nothing rolls them back. Fix the cause and run migrate again; the finished connections have nothing pending.

migrate:rollback never spans databases. With connection directories present, it needs --connection=<name> or MIGRATE_CONNECTION_NAME, and without either it prints its usage to STDERR and exits 1. A project without connection directories rolls back the default connection.

Give each connection its own database. Two connections pointing at the same database share its kinetis_migrations table, and each one then sees the other’s migrations as applied migrations with no file, which is not supported.

From your own code

The commands run a MigrationRunner per connection. It takes the link it runs on, the ledger repository, and that connection’s migrations directory, and runs that one directory on that one link:

use Kinetis\DatabaseBridge\ConnectionFactory;
use Kinetis\Migrations\MigrationRunner;
use Kinetis\Migrations\SqlMigrationRepository;

$db = ConnectionFactory::singleSession($config, 'reporting');

try {
    $runner = new MigrationRunner($db, new SqlMigrationRepository($db), $projectRoot . '/migrations/reporting');

    $runner->migrate();  // runs every pending migration, in filename order; returns their names
    $runner->rollback(); // rolls back the migration applied most recently; returns its name, or null
    $runner->status();   // every migration, with whether it is applied
} finally {
    $db->close();
}

The link has to be a single-session client, since the run’s lock lives in that session (see “Concurrent deploys are safe” below). The runner does not close it; its owner does.

What the ledger records, and what it checks

kinetis_migrations holds one row per applied migration: the migration name, the checksum (SHA-256) of the file that ran, and the application_order this database applied it in.

migrate, migrate:rollback and migrate:status all verify each connection’s ledger against that connection’s directory before doing anything else. Every applied migration must still have a file, and that file must still hash to the checksum recorded when it ran. The first one that fails either check throws Exception\MigrationIntegrityException, naming the migration and the reason, before any up(), down() or ledger write. Never edit a migration that has been deployed: restore the deployed file and the commands run again, and write a new migration for the change. Nothing rewrites the ledger to match a changed file, because only the file that ran describes what the database holds.

Moving a deployed migration to another connection’s directory is the same violation: the connection that applied it no longer has its file. Move it back, and write a new migration in the other directory for the change that belongs there.

migrate:rollback undoes the migration with the highest application_order — the one this database applied most recently, which is not always the one whose name sorts last. A migration merged from another branch and applied after a later-timestamped one is the first to come back off, and a migration rolled back and applied again is the newest one from then on.

Transactions are not automatic

A migration’s up()/down() runs exactly as written — the runner never wraps it in a transaction. PostgreSQL supports transactional DDL; MySQL’s DDL statements auto-commit regardless of any surrounding transaction, so a runner-imposed transaction would be real atomicity on one backend and a false sense of it on the other. A migration that wants atomicity on PostgreSQL opens one itself, inside its own up():

public function up(MysqlLink|PostgresLink $db): void
{
    $tx = $db->beginTransaction();

    try {
        $tx->execute('...');
        $tx->execute('...');
        $tx->commit();
    } catch (\Throwable $e) {
        $tx->rollback();
        throw $e;
    }
}

If a migration’s up() throws partway through a migrate run, every migration before it in that run is already recorded as applied, and the failing one is not. The exception propagates, so the run stops there instead of continuing past a failure.

Warning

Statements a failing up() already ran stay applied unless the migration wrapped them in its own transaction, which on MySQL DDL cannot do. The migration stays pending, so running migrate again re-runs its first statements against a schema that already has them. Repair the schema by hand, or make each statement safe to repeat, before running it again.

Concurrent deploys are safe

migrate and migrate:rollback hold a cross-process advisory lock on each database for the whole of that database’s run, so two deploy instances starting at the same time cannot both compute the same pending set and run it twice: the second waits for the first to finish before it looks at what is pending. Waiting longer than 10 seconds throws Exception\MigrationLockTimeoutException, most often meaning another migrate or migrate:rollback is still running elsewhere; retry once it finishes.

The lock belongs to the database session, so it releases on its own when the connection holding it closes — gracefully or not — with nothing to clean up if a process is killed mid-migration. That is why the commands connect through ConnectionFactory::singleSession(), over PDO whatever DB_DRIVER says. If that session is lost mid-run, a migration abandoning a transaction being enough, the run stops with Kinetis\Persistence\Exception\ConnectionException rather than carrying on unlocked. Single-session clients describes the client.

See also

  • Query Builder — querying the tables these migrations create, on the same MySQL/PostgreSQL connections.

  • Database — the DB_* connection the migrate* commands read.

  • Configuration — the .env/environment convention and named connections.