Migrations
Versioned schema changes, batches, rollbacks, and why down() is not optional.
Migrations live in database/migrations/. Each file returns a Migration:
<?php
// database/migrations/0003_create_articles.php
use PhpOrbit\Database\Connection;
use PhpOrbit\Database\Migration;
return new class implements Migration {
public function up(Connection $database): void
{
$database->executeSchema(
'CREATE TABLE articles (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
body TEXT NOT NULL,
author_id INTEGER NOT NULL REFERENCES users(id),
created_at TEXT NOT NULL
)',
);
$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.
MigrationFailed: Migration file "AddArticles" is unusable: names must look like
"0001_create_users" — digits, an underscore, then lowercase wordsRunning them
$ ./orbit migrate
Applying pending migrations...
0003_create_articles
0004_add_articles_slug
$ ./orbit 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
$ ./orbit migrate:rollback
Reversed 0003_create_articles
$ ./orbit migrate:rollback --batches=2./orbit serve applies pending migrations first, as a development convenience. The production entrypoints never touch the schema — several workers booting at once would race. Run ./orbit migrate as a deploy step.
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
$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.
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
use PhpOrbit\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
public function up(Connection $database): void
{
$database->executeSchema('ALTER TABLE users ADD COLUMN display_name TEXT');
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 orbit_migrations:
| Column | Meaning |
|---|---|
name | Filename without .php. Primary key. |
batch | Which run applied it. |
applied_at | UTC timestamp. |
The table is created on demand, so a fresh database needs no setup step.
Using the Migrator directly
<?php
use PhpOrbit\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 reversedThis 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. It belongs in ./orbit db:seed, which is idempotent and safe to re-run:
$ ./orbit db:seed
Seeded demo account: demo@example.test / correct-horse-battery