Pop PHP
Database

Migrations

A migration is a pair of schema changes in one class: up() applies it and down() takes it back off. Kettle scaffolds them, runs them in timestamp order and tracks how far it has got. Schema Builder covers building the statements themselves.

Writing a Migration#

A migration is a class extending Pop\Db\Sql\Migration\AbstractMigration with an up() and a down(). The adapter is already on it as $this->db, so the body is the schema builder and nothing else:

PHPdatabase/migrations/default/20260101120000_create_widgets_table.php
<?php
declare(strict_types=1);

use Pop\Db\Sql\Migration\AbstractMigration;

class CreateWidgetsTable extends AbstractMigration
{

    public function up(): void
    {
        $schema = $this->db->createSchema();
        $schema->create('widgets')
            ->int('id', 16)->increment()
            ->varchar('name', 255)->notNullable()
            ->primary('id');

        $schema->execute();
    }

    public function down(): void
    {
        $schema = $this->db->createSchema();
        $schema->dropIfExists('widgets');
        $schema->execute();
    }

}

Write down() if you ever intend to roll back — an empty one rolls back cleanly and changes nothing. The leading timestamp in the filename is what orders migrations against each other.

Nothing about up() restricts you to DDL. $this->db is a full adapter, so a migration that adds a column and backfills it runs the UPDATE right there — see Querying for the adapter API and Query Builder for building that statement portably.

Running Migrations with Kettle#

./kettle scaffolds and runs migrations against the connections in app/config/database.php. Each connection key gets its own directory under database/migrations/, and the optional <database> argument on every command below picks one — default when you leave it off, or all for every directory in turn.

BASH
./kettle migrate:create CreateWidgetsTable
./kettle migrate:run
./kettle migrate:run all
./kettle migrate:rollback
Command Does
migrate:create <class> [<database>] writes a timestamped migration file into database/migrations/<database>/
migrate:run [<steps>] [<database>] runs the next <steps> pending migrations, one by default; all runs every pending one
migrate:rollback [<steps>] [<database>] calls down() on the last <steps> applied migrations, one by default
migrate:reset [<database>] rolls every applied migration back
migrate:point [<id>] [<database>] moves the recorded position without running anything — a migration's timestamp, or latest
db:config [<database>] prompts for credentials and writes them into app/config/database.php
db:test [<database>] checks that the configured connection can be opened

migrate:create appends a uniqid() to the class name you give it, so CreateWidgetsTable becomes a file holding class CreateWidgetsTable68f2a1c9b0e4d. Rename the class and the file together if you would rather not carry that, or call Pop\Db\Sql\Migrator::create() yourself, which does not add it.

Position is kept in a .current file holding the timestamp of the last migration applied, and a full rollback deletes it. Write a .table file there instead, naming a Pop\Db\Record class, and the migrator keeps state in that table — id, file, batch number and timestamp per row. migrate:point rewrites the position either way.

Running Migrations from Code#

Pop\Db\Sql\Migrator is what the migrate: commands drive, and nothing stops you driving it yourself — a deploy script, a test bootstrap, or a setup command of your own. It takes the adapter and the directory holding the migration files:

PHP
use Pop\Db\Record;
use Pop\Db\Sql\Migrator;

$migrator = new Migrator(Record::getDb(), __DIR__ . '/../database/migrations/default');

$migrator->run();

run() and rollback() take a number of steps, one by default; runAll() and rollbackAll() cover the whole directory. Migrator::create($class, $path) writes a new migration file and returns its name, without the uniqid() suffix migrate:create appends.

Migrations are applied in batches, and the migrator tracks them: getCurrentBatch() is the batch last applied, getNextBatch() the one the next run will use, and getByBatch($batch) lists the migrations in a given batch. getCurrent() returns the position itself — the timestamp in the .current file, or the last row in the state table.

See Also#

  • Schema Builder — the createSchema() API a migration's up() and down() are written in
  • Seeding — the seed classes and .sql files that sit beside migrations
  • Querying — the adapter a migration's $this->db is, and its transaction API
  • Records & the ORM — the table classes a migration's schema is written for
  • Connecting & Adaptersapp/config/database.php and the connections Kettle reads from it
  • pop-db README — the migrator API surface