Migrations

Versioned schema changes, batches, rollbacks, and why down() is not optional.

Migrations live in database/migrations/. Each file returns a Migration:

PHP

<?php
// database/migrations/0003_create_articles.php
use PhpOnWheels\Database\Connection;
use PhpOnWheels\Database\Migration;

return new class implements Migration {
    public function up(Connection $database): void
    {
        $database->executeSchema(sprintf(
            'CREATE TABLE articles (
                id %s,
                title %s NOT NULL,
                body %s NOT NULL,
                created_at VARCHAR(40) NOT NULL
            )',
            $database->driver()->autoIncrementPrimaryKey(),
            $database->driver()->textType(),
            $database->driver()->textType(),
        ));

        $database->executeSchema('CREATE INDEX articles_author ON articles(author_id)');
    }

    public function down(Connection $database): void
    {
        $database->executeSchema('DROP TABLE articles');
    }
};

Naming

<digits>_<lowercase_words>.php — for example 0003_create_articles.php or 20260810143000_add_articles_slug.php.

Ordering is by filename, so two developers adding migrations on separate branches get a deterministic order once merged, rather than one that depends on class discovery. A timestamp prefix makes collisions unlikely; a counter is fine for a small team.

Output

MigrationFailed: Migration file "AddArticles" is unusable: names must look like
  "0001_create_users" — digits, an underscore, then lowercase words

Running them

Shell

$ ./pow migrate
Applying pending migrations...
  0003_create_articles
  0004_add_articles_slug

$ ./pow migrate:status
  applied   0001_create_users            batch 1
  applied   0002_create_auth_attempts    batch 1
  applied   0003_create_articles         batch 2
  pending   0004_add_articles_slug

$ ./pow migrate:rollback
Reversed 0003_create_articles

$ ./pow migrate:rollback --batches=2

./pow serve applies pending migrations first, as a development convenience. The production entrypoints never touch the schema — several workers booting at once would race. Run ./pow migrate as a deploy step.

Upgrading from the former Orbit name preserves migration history: when the new phponwheels_migrations ledger is empty, the migrator transactionally moves records from orbit_migrations, retaining batch numbers and timestamps without rerunning schema changes. The old ledger is left empty so rollbacks cannot resurrect imported records. If both ledgers contain history, migration stops for manual reconciliation rather than guessing which is authoritative. Back up your database before upgrading; do not delete existing tables to resolve a ledger mismatch.

Batches

Everything applied by one migrate run shares a batch number. A rollback reverses the most recent batch — one deployment's worth of changes — rather than one arbitrary step.

PHP

<?php
$migrator->batches();   // ['0001_create_users' => 1, '0003_create_articles' => 2]

Every migration in the batch is loaded and checked before anything is undone, so a batch containing a missing file fails without having half-rolled-back the rest.

Transactions

Each migration runs inside its own transaction. A failure leaves the schema as it was and the ledger unchanged, and stops the run — later migrations almost certainly assume this one's changes exist.

This depends on your engine

SQLite and PostgreSQL support transactional DDL, so the guarantee holds. MySQL does not: a failed migration there can leave partial changes behind, and you will need to clean up by hand. Keep MySQL migrations small for that reason.

down() is required

The interface demands it, so writing a migration forces a moment's thought about undoing it. When a change genuinely cannot be reversed, say so:

PHP

<?php
use PhpOnWheels\Database\IrreversibleMigration;

return new class implements Migration {
    public function up(Connection $database): void
    {
        $database->executeSchema('ALTER TABLE users DROP COLUMN legacy_token');
    }

    public function down(Connection $database): void
    {
        throw IrreversibleMigration::because('the legacy_token values were not retained.');
    }
};

That records the decision in the migration itself, instead of an empty method that silently “succeeds” and leaves the schema wrong.

Data migrations

Schema and data change together in one transaction:

PHP

<?php
public function up(Connection $database): void
{
    $database->executeSchema(sprintf(
        'ALTER TABLE users ADD COLUMN display_name %s',
        $database->driver()->textType(),
    ));

    foreach ($database->select('SELECT id, email FROM users') as $user) {
        $database->execute(
            'UPDATE users SET display_name = :name WHERE id = :id',
            [
                'name' => explode('@', (string) $user['email'])[0],
                'id' => $user['id'],
            ],
        );
    }
}

For a large table, prefer a single UPDATE over a loop — it is one statement instead of one per row.

The ledger

Applied migrations are recorded in phponwheels_migrations:

ColumnMeaning
nameFilename without .php. Primary key.
batchWhich run applied it.
applied_atUTC timestamp.

The table is created on demand, so a fresh database needs no setup step.

Using the Migrator directly

PHP

<?php
use PhpOnWheels\Database\Migrator;

$migrator = new Migrator(
    $database,
    $root . '/database/migrations',
    report: static fn (string $line) => print($line . PHP_EOL),
);

$migrator->pending();     // list<string>
$migrator->applied();     // list<string>
$migrator->available();   // every file, in run order
$migrator->migrate();     // returns what it applied
$migrator->rollback(1);   // returns what it reversed

This is what makes migrations testable — run them against an in-memory SQLite database in a test and assert the resulting schema.

Seeding

Seed data is not a migration. Put PHP files in database/seeders/, each returning a PhpOnWheels\Database\Seeder. Run migrations first, then seed explicitly:

Generate a starter with ./pow make:seed 0002_categories, then edit its run() method. Names become filenames exactly; use a numeric prefix to choose the order. Existing files are refused unless --force is passed.

Shell

$ ./pow db:seed
Seeded 0001_demo_account

Files run in filename order on every invocation, with no ledger or automatic rollback command. Each seeder runs in its own transaction. If it fails, its data writes roll back and later seeders do not run; earlier successful seeders remain committed. All files are loaded and checked before the first seeder runs. Keep file-level code free of side effects and put writes inside run(). Use seeders for data, not DDL: MySQL schema changes can commit implicitly.

For example, database/seeders/0001_starter_note.php:

PHP

<?php
declare(strict_types=1);

use PhpOnWheels\Database\Connection;
use PhpOnWheels\Database\Seeder;

return new class implements Seeder {
    public function run(Connection $database): void
    {
        if ($database->query('notes')->where('title', '=', 'Welcome')->first() === null) {
            $database->query('notes')->insert([
                'title' => 'Welcome',
                'body' => 'Your first note.',
                'created_at' => gmdate('c'),
            ]);
        }
    }
};

Make each seeder idempotent by checking for existing data rather than inserting duplicates or overwriting user changes. The demo seeder creates demo@example.test / correct-horse-battery only if that account is absent. These are local demo credentials, not production credentials. Blank projects have an empty seed directory; an empty or absent directory prints Nothing to seed.. Seeders never run automatically during boot or serve.