dakujem / migrun
Lightweight, flexible, framework-and-database-agnostic migration runner.
Requires
- php: ^8.2
Requires (Dev)
- mikey179/vfsstream: ^1.6.12
- phpunit/phpunit: ^11
- psr/container: ^2.0
Suggests
- psr/container: >=1.0 โ required for ContainerInvoker autowiring support
README
A lightweight, flexible migration runner for your PHP stack.
Database migrations on your terms.
No framework lock-in. No config files. Any database.
๐ฟ
composer require dakujem/migrun๐ Changelog
What is Migrun?
Migrun looks for migration files and makes sure each one has run exactly once. It is a lightweight, hackable tool to help you manage database changes consistently. It is intended to be incorporated into your existing project setup.
Migrun does not:
- provide a migration framework
- provide a query builder
- provide a database abstraction layer or any sort of ORM
- enforce usage of a particular database connection type or library
Migration file format
Migrun imposes no restrictions on filenames.
By default, any .php file placed in the configured directory is picked up as a migration.
Each migration MUST return a callable class or closure. This is a distinction compared to other migration runners.
Execution order
Migrations are ordered by their ID โ the filename stem (the path relative to the migrations
directory, without the .php extension).
The order in which migrations run is therefore determined entirely by the filename.
IDs are compared lexicographically โ byte by byte, exactly as LC_ALL=C sort or git would
compare them. It is deterministic and identical on every platform, filesystem and locale. This
one comparison decides the order pending migrations run in and the order status lists them.
โ ๏ธ Digits are compared as characters, not as numbers. Byte-wise
'10'comes before'9', because'1'comes before'9'.
This matters only when a number in the filename varies in width. See Numbering your migrations below.
Rollback is the one exception. Migrations are reverted in reverse order of application rather than of ID, so that "roll back the last one" undoes what was actually done last. The two coincide unless a migration was applied out of order โ see Rollback order follows application order.
Recommended naming convention
For execution order to match creation order, prefix each filename with a timestamp:
YYYYMMDD_HHMMSS_<name>.php
Examples:
20240101_120000_create_users.php
20240115_093000_add_email_index.php
Every timestamp is the same width, so lexicographic and chronological order coincide. This is the recommended scheme precisely because it cannot run into the numbering pitfall below.
The timestamp in the filename is purely for ordering. The history storage records the time the migration ran, not the time encoded in the filename.
Numbering your migrations
If you prefer plain version numbers to timestamps, zero-pad them to a fixed width:
0001_create_users.php โ
correct
0002_add_email_index.php
0009_backfill_slugs.php
0010_add_orders.php
1_create_users.php โ wrong order once you reach 10
2_add_email_index.php
9_backfill_slugs.php
10_add_orders.php โ runs FIRST, before 1_create_users
Unpadded numbers sort as 1, 10, 2, 9 โ so the tenth migration runs before the second.
Pad wide enough for the number of migrations you expect (three digits gets you to 999).
Using 6 digits is not an overkill but a precaution.
A prefix does not help. The comparison is still byte-wise, so v1 โฆ v10 breaks in exactly
the same way:
v1.php v2.php v9.php v10.php โ runs as v1, v10, v2, v9 โ
v001.php v002.php v009.php v010.php โ runs as v001, v002, v009, v010 โ
The same applies to rel-1, step_1, 2024-1, and any other scheme with an unpadded number
anywhere in the name. Pad the digits.
โ ๏ธ Upgrading from 1.0 with plain numeric filenames (
1.php,2.php, โฆ10.php)? The ID comparison was fixed in 1.0.1 and their order changed. See the changelog for what to do.
Migration format A โ anonymous class (up + down)
The file returns an anonymous class with up() and down() methods. No interface required.
Typed parameters are autowired from the PSR-11 container by class name (when using ContainerInvoker).
<?php // migrations/20240115_093000_add_email_index.php use PDO; return new class { public function up(PDO $db): void { $db->exec('CREATE INDEX idx_users_email ON users (email)'); } public function down(PDO $db): void { $db->exec('DROP INDEX idx_users_email'); } };
Migration format B โ anonymous class (up only)
The file returns an anonymous class with an up() method only. Rollback is not supported.
<?php // migrations/20240101_120000_create_users.php use PDO; return new class { public function up(PDO $db): void { $db->exec('CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT NOT NULL)'); } };
Migration format C โ callable (up only)
The file returns a callable. Rollback is not supported.
<?php // migrations/20240101_120000_create_users.php use PDO; return function (PDO $db): void { $db->exec('CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT NOT NULL)'); };
Edge case: if a returned object has both a public
up()method and is itself callable (i.e. defines__invoke),up()takes precedence and__invokeis never used.
Migration format D โ interface-based anonymous class
For projects that prefer explicit contracts, the file may return an instance implementing Migration or ReversibleMigration.
Behaviour is identical to returning a class defining either just the up method or both the up and down methods.
<?php // migrations/20240115_093000_add_email_index.php use Dakujem\Migrun\ReversibleMigration; use PDO; return new class implements ReversibleMigration { public function up(?PDO $db = null): void { $db->exec('CREATE INDEX idx_users_email ON users (email)'); } public function down(?PDO $db = null): void { $db->exec('DROP INDEX idx_users_email'); } };
Methods
up()anddown()may declare typed parameters beyond the parameter-less interface signature. PHP's LSP rules require these to have default values, but the invoker will override the defaults with container-resolved instances automatically.
Quick setup
Recommended โ PDO storage with a container
The most practical setup: store the migration history in the same database you are migrating. This keeps everything in one place, avoids a separate file to manage or gitignore, and lets the history participate in database backups and restores naturally.
<?php require __DIR__ . '/vendor/autoload.php'; use Dakujem\Migrun\MigrunBuilder; $container = require __DIR__ . '/bootstrap/container.php'; // any PSR-11 container // $pdo is resolved from the container โ the same database being migrated $pdo = $container->get(PDO::class); $orchestrator = (new MigrunBuilder()) ->directory(__DIR__ . '/migrations') ->container($container) // enables autowiring of migration parameters ->pdoStorage($pdo) // history stored in the same DB, table: migrun_migrations ->build(); $executed = $orchestrator->run(); foreach ($executed as $migration) { echo "Migrated: {$migration->id()}" . PHP_EOL; }
PdoStorage creates the history table automatically on first use. Works with MySQL, PostgreSQL, SQLite, and any other PDO-compatible database.
Minimal โ no options
The absolute minimum: only the migrations directory is required. Everything else uses built-in defaults.
$orchestrator = (new MigrunBuilder()) ->directory(__DIR__ . '/migrations') ->build();
Storage defaults to {migrations-dir}/.migrun/migrun.json โ no extra configuration needed. Migration files must accept no arguments (or have all defaults).
Important: The default JSON storage file tracks which migrations have already run. If it is committed to version control and then overwritten (e.g. reset to an earlier state or deleted), Migrun will re-run migrations that have already been applied. Add the file to
.gitignoreto prevent this:# migrun storage {migrations-dir}/.migrun/*This covers both the default JSON file and the default SQLite file, since both live under
.migrun/. If you configure a custom storage path, gitignore that path instead. Using PDO storage in the same database avoids this concern entirely.
All builder options
use Dakujem\Migrun\MigrunBuilder; $orchestrator = (new MigrunBuilder()) ->directory(__DIR__ . '/migrations') // required; pass recursive: false to disable subdirectory scanning ->container($container) // PSR-11 container; omit for no-autowiring mode ->pdoStorage($pdo) // recommended: history in the same DB as migrations ->reporter(new MyReporter()) // live progress as each migration runs; omit for none ->build();
Storage backend (mutually exclusive โ build() throws if more than one is set):
// Any PDO connection (MySQL, PostgreSQL, SQLite, โฆ) โ recommended ->pdoStorage($pdo) // default table name (migrun_migrations) ->pdoStorage($pdo, table: 'schema_history') // custom table name // mysqli connection (MySQL/MariaDB only) ->mysqliStorage($mysqli) // default table name (migrun_migrations) ->mysqliStorage($mysqli, table: 'schema_history') // custom table name // SQLite database file ->sqliteStorage() // {migrations-dir}/.migrun/migrun.sqlite ->sqliteStorage(__DIR__ . '/var/history.sqlite') // explicit path ->sqliteStorage(table: 'schema_history') // default path, custom table name // JSON file โ default when nothing is set ->fileStorage(__DIR__ . '/var/migrun') // directory โ appends /migrun.json // file path โ used as-is // omit โ {migrations-dir}/.migrun/migrun.json
Full manual composition
The builder is a convenience layer. Every collaborator can be constructed and wired by hand for complete control.
<?php require __DIR__ . '/vendor/autoload.php'; use Dakujem\Migrun\Executor\ContainerInvoker; use Dakujem\Migrun\Executor\Executor; use Dakujem\Migrun\Executor\TrivialInvoker; use Dakujem\Migrun\Finder\DirectoryFinder; use Dakujem\Migrun\Orchestrator; use Dakujem\Migrun\Storage\JsonFileStorage; $container = require __DIR__ . '/bootstrap/container.php'; $orchestrator = new Orchestrator( storage: new JsonFileStorage(__DIR__ . '/storage/migrations.json'), finder: new DirectoryFinder(__DIR__ . '/migrations', recursive: false), executor: new Executor(new ContainerInvoker($container)), ); // No container / no autowiring: // executor: new Executor(new TrivialInvoker()), $executed = $orchestrator->run(); foreach ($executed as $migration) { echo "Migrated: {$migration->id()}" . PHP_EOL; } // Roll back the last migration // $reverted = $orchestrator->rollback(1);
Running and rolling back
Every method returns MigrationRun[] โ the migrations it actually executed, each carrying its
measured duration.
| Method | Does |
|---|---|
run() |
Applies every pending migration. |
runTo($id) |
Applies pending migrations up to and including $id; later ones stay pending. |
rollback($steps = 1) |
Reverts the $steps most recently applied migrations. |
rollbackBefore($id) |
Reverts $id and everything applied after it, leaving the state before $id. |
rollbackAll() |
Reverts everything applied. |
rollbackExactly($ids) |
Reverts exactly the named migrations, in the order given. |
status() |
Reports every known migration, ascending by ID. |
One rule ties the targeted methods together:
The migration you name is always acted upon.
runTo($id)applies it,rollbackBefore($id)reverts it,rollbackExactly([$id])reverts it.
So picking the newest applied migration in a UI and calling rollbackBefore() reverts exactly that
one โ never a no-op.
Rollback order follows application order
This matters, so it is worth being explicit: rollback proceeds in reverse order of application, not of ID. "Roll back the last migration" means the one applied most recently, which is not necessarily the one with the highest ID.
Consider a branch merged into a database that already had newer migrations applied:
0001_create_users applied 2026-07-01 09:00
0002_add_widget applied 2026-07-28 14:22 โ applied LAST, despite the lower ID
0003_add_orders applied 2026-07-05 11:30
rollback(1) reverts 0002_add_widget โ the migration you actually applied last โ rather than
0003_add_orders, which belongs to someone else. Reverse-application order also unwinds the schema
in the exact reverse of how it was built, so each down() runs against the state its up() produced.
In the normal case the two orders are the same thing โ they diverge only when a migration was
applied out of order, as above. status() lists ascending by ID while showing each applied_at, so
that situation shows up as a non-monotonic timestamp column: the quickest way to spot it before
rolling anything back.
rollbackBefore($id) chooses its set by ID โ the same byte-wise comparison status() lists by, so
it is exactly "this entry and everything below it" โ but reverts that set in application order. Use
rollbackExactly() when you need to control the sequence yourself.
Reverting specific migrations
rollbackExactly() is the escape hatch for the branch workflow: revert your own migration while
newer ones from elsewhere stay applied.
// Undo just this one, out of the middle of the history. $runner->rollbackExactly(['20260701_120000_add_widget']);
It imposes no ordering of its own โ migrations are reverted in the order the array lists them. A status listing is ascending by ID, which is the opposite of a sensible rollback order, so reverse it first:
$displayed = ['0002_add_widget', '0003_add_orders']; // as shown, top to bottom $runner->rollbackExactly($displayed); // โ reverts 0002 before 0003 $runner->rollbackExactly(array_reverse($displayed)); // โ newest first
Reverting out of order deliberately leaves a gap: the migration becomes pending again, and a
later run() re-applies it at its ID position โ therefore after migrations with higher IDs. Migrun
supports this (it has always tolerated gaps, since each migration is tracked individually rather than
by a high-water mark), but whether it is safe for your schema is your call. A migration that ran
against a newer schema may not revert cleanly against an older one.
rollbackBefore() and rollbackExactly() throw MigrationNotAppliedException if a named migration
is not currently applied, and runTo() throws MigrationNotFoundException for an unknown target โ
a mistyped or truncated ID must never silently act on a different set.
Running from the CLI
Migrun ships no CLI command of its own โ keeping it decoupled from any console framework. The recommended pattern is a small standalone PHP script you invoke directly.
Standalone script
Create bin/migrate.php (or wherever suits your project):
<?php require __DIR__ . '/../vendor/autoload.php'; use Dakujem\Migrun\MigrationState; use Dakujem\Migrun\MigrunBuilder; $container = require __DIR__ . '/../bootstrap/container.php'; $orchestrator = (new MigrunBuilder()) ->directory(__DIR__ . '/../migrations') ->container($container) ->build(); $command = $argv[1] ?? 'run'; match ($command) { 'run' => (function () use ($orchestrator) { $executed = $orchestrator->run(); if (empty($executed)) { echo "Nothing to run." . PHP_EOL; return; } foreach ($executed as $m) { echo "Migrated: {$m->id()}" . PHP_EOL; } })(), 'rollback' => (function () use ($orchestrator, $argv) { $steps = (int) ($argv[2] ?? 1); $reverted = $orchestrator->rollback($steps); foreach ($reverted as $m) { echo "Reverted: {$m->id()}" . PHP_EOL; } })(), 'status' => (function () use ($orchestrator) { $entries = $orchestrator->status(); if (empty($entries)) { echo "No migrations found." . PHP_EOL; return; } $idWidth = max(array_map(fn($e) => strlen($e->id), $entries)); $idWidth = max($idWidth, 2); // minimum column width $header = sprintf( "%-{$idWidth}s %-7s %s", 'Migration ID', 'Status', 'Applied at (UTC)' . ' ', ); echo $header . PHP_EOL; echo str_repeat('-', strlen($header)) . PHP_EOL; $up = $down = 0; $missingSince = null; foreach ($entries as $entry) { echo sprintf( "%-{$idWidth}s %-7s %s", $entry->id, match ($entry->state) { MigrationState::Applied => 'up', MigrationState::Pending => 'down', MigrationState::Missing => 'MISSING', }, $entry->appliedAt?->format('Y-m-d H:i:s') ?? '-', ) . PHP_EOL; $up += MigrationState::Applied === $entry->state ? 1 : 0; $down += MigrationState::Pending === $entry->state ? 1 : 0; if (MigrationState::Missing === $entry->state) { $missingSince = $entry->appliedAt; } } echo str_repeat('-', strlen($header)) . PHP_EOL; if ($missingSince) { echo "WARNING! Some migration files missing since {$missingSince->format('Y-m-d H:i:s')}." . PHP_EOL; } echo "Total: {$up} up, {$down} down" . PHP_EOL; })(), default => (function () use ($command) { echo "Unknown command: {$command}" . PHP_EOL; echo "Usage: migrate.php [run|rollback [steps]|status]" . PHP_EOL; exit(1); })(), };
Make it executable and run:
php bin/migrate.php run php bin/migrate.php rollback php bin/migrate.php rollback 3 php bin/migrate.php status
๐ A complete, ready-to-adapt version of this script lives in
examples/migrate.php. It builds on the snippet above with live progress output, concurrency locking, acreatescaffolder, colored terminal output, and CI-friendly exit codes. Start there if you want the full picture of a finished Migrun integration โ the sections below explain each piece of it in isolation.
Via Composer scripts
Add the commands to the scripts section of your composer.json:
{
"scripts": {
"migrate:up": "@php bin/migrate.php run",
"migrate:down": "@php bin/migrate.php rollback",
"migrate:status": "@php bin/migrate.php status"
}
}
Then run:
composer migrate:up composer migrate:down composer migrate:status
Pass extra arguments with --:
composer migrate:down -- 3
Symfony Console command
If your project already uses Symfony Console:
<?php use Dakujem\Migrun\MigrationState; use Dakujem\Migrun\Orchestrator; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; final class MigrateCommand extends Command { public function __construct(private Orchestrator $runner) { parent::__construct('db:migrate'); } protected function configure(): void { $this->addArgument('command', InputArgument::OPTIONAL, 'run | rollback | status', 'run'); $this->addArgument('steps', InputArgument::OPTIONAL, 'Number of migrations to roll back', 1); } protected function execute(InputInterface $input, OutputInterface $output): int { return match ($input->getArgument('command')) { 'run' => $this->runMigrations($output), 'rollback' => $this->rollback($output, (int) $input->getArgument('steps')), 'status' => $this->status($output), default => (function () use ($input, $output) { $output->writeln("<error>Unknown command: {$input->getArgument('command')}</error>"); return Command::FAILURE; })(), }; } private function runMigrations(OutputInterface $output): int { $executed = $this->runner->run(); if (empty($executed)) { $output->writeln('Nothing to run.'); } foreach ($executed as $m) { $output->writeln("Migrated: {$m->id()}"); } return Command::SUCCESS; } private function rollback(OutputInterface $output, int $steps): int { $reverted = $this->runner->rollback($steps); foreach ($reverted as $m) { $output->writeln("Reverted: {$m->id()}"); } return Command::SUCCESS; } private function status(OutputInterface $output): int { $entries = array_filter( iterator_to_array($this->runner->status()), fn($e) => $e->state !== MigrationState::Missing, ); if (empty($entries)) { $output->writeln('No migrations found.'); return Command::SUCCESS; } $idWidth = max(array_map(fn($e) => strlen($e->id), $entries)); $idWidth = max($idWidth, 2); $output->writeln(sprintf("%-{$idWidth}s %-7s %s", 'ID', 'Status', 'Applied at')); $output->writeln(str_repeat('-', $idWidth + 22)); foreach ($entries as $entry) { $output->writeln(sprintf( "%-{$idWidth}s %-7s %s", $entry->id, match ($entry->state) { MigrationState::Applied => 'up', MigrationState::Pending => 'down', MigrationState::Missing => 'MISSING', }, $entry->appliedAt?->format('Y-m-d H:i:s') ?? '-', )); } return Command::SUCCESS; } }
Wire it the same way as any other command in your framework:
php bin/console db:migrate php bin/console db:migrate rollback php bin/console db:migrate rollback 3 php bin/console db:migrate status
Live progress output
The examples above collect the result array and print it after the whole batch finishes. For a long-running set โ or just nicer feedback โ you can report progress as each migration runs by giving the runner a reporter.
A reporter implements ReportsMigrations. The Orchestrator calls it around
every individual migration:
starting()โ a migration is about to run (Up or Down),finished()โ it succeeded (with its measured duration),failed()โ it threw; the run then aborts as usual, re-throwing the error.
Because failed() receives the exact migration that threw, you no longer need to
reconstruct where a run stopped โ the reporter names it directly.
A plain reporter (standalone script)
Aligned with the bin/migrate.php script above โ it just writes to stdout:
<?php use Dakujem\Migrun\Direction; use Dakujem\Migrun\MigrationFile; use Dakujem\Migrun\MigrationRun; use Dakujem\Migrun\ReportsMigrations; final class EchoReporter implements ReportsMigrations { public function starting(MigrationFile $file, Direction $direction): void { $verb = $direction === Direction::Up ? 'Migrating' : 'Reverting'; echo "{$verb} {$file->id()} ... "; } public function finished(MigrationRun $run, Direction $direction): void { echo sprintf('done (%.3fs)', $run->durationSeconds) . PHP_EOL; } public function failed(MigrationFile $file, Direction $direction, \Throwable $error): void { echo 'FAILED' . PHP_EOL; echo " {$error->getMessage()}" . PHP_EOL; } }
Wire it via the builder โ everything else in the script stays the same:
$orchestrator = (new MigrunBuilder()) ->directory(__DIR__ . '/../migrations') ->container($container) ->reporter(new EchoReporter()) // live progress as each migration runs ->build(); // run()/rollback() now print as they go; the returned array is still // available if you want a final summary on top. $orchestrator->run();
Output while running:
Migrating 20240101_120000_create_users ... done (0.012s)
Migrating 20240115_093000_add_email_index ... done (0.004s)
Migrating 20240120_080000_backfill_slugs ... FAILED
SQLSTATE[23000]: Integrity constraint violation: 1062 Duplicate entry
Only need to react to some events? Extend
NullReporter(a no-op base) and override just the methods you care about, instead of implementing the full interface. This also keeps your reporter working if the contract ever grows.
A Symfony Console reporter
Aligned with the MigrateCommand above, this one writes through the command's
OutputInterface and uses console styling tags:
<?php use Dakujem\Migrun\Direction; use Dakujem\Migrun\MigrationFile; use Dakujem\Migrun\MigrationRun; use Dakujem\Migrun\ReportsMigrations; use Symfony\Component\Console\Output\OutputInterface; final readonly class ConsoleReporter implements ReportsMigrations { public function __construct(private OutputInterface $output) {} public function starting(MigrationFile $file, Direction $direction): void { $verb = $direction === Direction::Up ? 'Migrating' : 'Reverting'; $this->output->write("{$verb} <info>{$file->id()}</info> ... "); } public function finished(MigrationRun $run, Direction $direction): void { $this->output->writeln(sprintf('<comment>done (%.3fs)</comment>', $run->durationSeconds)); } public function failed(MigrationFile $file, Direction $direction, \Throwable $error): void { $this->output->writeln('<error>FAILED</error>'); $this->output->writeln(" {$error->getMessage()}"); } }
The reporter needs the $output, which only exists inside execute(). Rather than
rebuild the runner per invocation, pass the reporter per call: run() and
rollback() accept an optional trailing ReportsMigrations argument that overrides
the constructor's reporter for that one call. So inject a normal, reusable
Orchestrator and hand it a fresh reporter each run:
final class MigrateCommand extends Command { // A plain, reusable service โ built once, injected like any other. public function __construct(private Orchestrator $runner) { parent::__construct('db:migrate'); } protected function execute(InputInterface $input, OutputInterface $output): int { // Request-scoped: the reporter wraps this invocation's $output. $reporter = new ConsoleReporter($output); match ($input->getArgument('command')) { 'run' => $this->runner->run($reporter), 'rollback' => $this->runner->rollback((int) $input->getArgument('steps'), $reporter), 'status' => $this->status($output), // unchanged โ status() takes no reporter default => throw new \InvalidArgumentException('Unknown command.'), }; return Command::SUCCESS; } }
See examples/migrate.php for a reporter that also handles
colored output, with TTY and NO_COLOR detection.
Passing a reporter through an abstraction
The base RunsMigrations contract deliberately declares no reporter parameter, so it
cannot express "pass a reporter". When you need that through an interface rather than
the concrete class โ typically in a decorator โ type against
RunsMigrationsWithReporter, which extends RunsMigrations and adds the parameter:
use Dakujem\Migrun\RunsMigrationsWithReporter; // Orchestrator implements this, so it is also a RunsMigrations. function migrate(RunsMigrationsWithReporter $runner, ReportsMigrations $reporter): void { $runner->run($reporter); }
Why this matters in a decorator. If you type the wrapped runner as plain
RunsMigrationsand still call$inner->run($reporter), PHP silently discards the argument for any implementation that does not declare it โ the reporter vanishes and live progress goes quiet, with no error at all. Static analysers do flag the call. UseRunsMigrationsWithReporterfor both the decorator and its inner runner.
Extending
Concepts
| Role | Interface | Built-in |
|---|---|---|
| Track applied migrations | TracksMigrations |
JsonFileStorage โ JSON file on diskPdoStorage โ any PDO databaseSqliteStorage โ SQLite file (wraps PdoStorage)MysqliStorage โ MySQL/MariaDB via mysqli |
| Discover migration files | DiscoversMigrations |
DirectoryFinder โ scans a directory |
| Invoke migration callables | InvokesCallable |
ContainerInvoker (PSR-11 autowired), TrivialInvoker (no args) |
| Load and run a migration | ExecutesMigrations |
Executor โ delegates to an InvokesCallable |
| Report progress during a run | ReportsMigrations |
NullReporter โ no-op default and base class |
| Orchestrate the whole flow | RunsMigrationsRunsMigrationsWithReporter โ adds the per-call reporter |
Orchestrator |
Every part is replaceable. Wire the built-ins for quick setup; swap them out as your project grows.
Custom storage
For the common case of a SQL database, use the built-in PdoStorage (or SqliteStorage):
use Dakujem\Migrun\Storage\PdoStorage; use Dakujem\Migrun\Storage\SqliteStorage; // Any PDO connection โ table is created automatically $storage = new PdoStorage($pdo); $storage = new PdoStorage($pdo, 'schema_history'); // custom table name // SQLite convenience wrapper $storage = new SqliteStorage(__DIR__ . '/var/migrun.sqlite');
Wire it via the builder:
(new MigrunBuilder()) ->directory(__DIR__ . '/migrations') ->pdoStorage($pdo) // or ->sqliteStorage() ->build();
For anything else โ Redis, S3, a remote API โ implement TracksMigrations directly:
use Dakujem\Migrun\MigrationFile; use Dakujem\Migrun\MigrationHistoryEntry; use Dakujem\Migrun\TracksMigrations; final class RedisStorage implements TracksMigrations { public function __construct(private \Redis $redis, private string $key = 'migrations') {} public function getApplied(): iterable { $entries = []; foreach ($this->redis->hGetAll($this->key) as $id => $at) { $entries[] = new MigrationHistoryEntry($id, new \DateTimeImmutable($at)); } usort($entries, fn($a, $b) => $b->at() <=> $a->at()); return $entries; } public function isApplied(MigrationFile $migration): bool { return (bool) $this->redis->hExists($this->key, $migration->id()); } public function markApplied(MigrationFile $migration, ?\DateTimeImmutable $at = null): void { $this->redis->hSet($this->key, $migration->id(), ($at ?? new \DateTimeImmutable())->format(\DateTimeImmutable::ATOM)); } public function markReverted(MigrationFile $migration, ?\DateTimeImmutable $at = null): void { $this->redis->hDel($this->key, $migration->id()); } }
Custom finder
Implement DiscoversMigrations to customize the way migrations are discovered (filtering, multiple directories, etc.).
Ordering is not your concern. Orchestrator sorts whatever list() returns, comparing IDs
byte by byte, so a finder cannot put run order out of step with rollback or status order. Return
files in any order you like. DirectoryFinder still sorts its own output, for the benefit of code
that uses the finder directly.
The same holds for storage: the orchestrator orders the migration history itself rather than trusting a database collation or a file's insertion order. There is exactly one ordering rule in the library, applied in one place.
Custom invoker
Implement InvokesCallable to integrate any DI framework's invoker. Below are ready-to-copy adapters for two common packages.
use Dakujem\Migrun\Executor\InvokesCallable; use Invoker\InvokerInterface; final readonly class PhpDiInvoker implements InvokesCallable { public function __construct(private InvokerInterface $invoker) {} public function invoke(callable $fn): mixed { return $this->invoker->call($fn); } }
Usage:
use Invoker\Invoker; use Invoker\ParameterResolver\Container\TypeHintContainerResolver; use Invoker\ParameterResolver\DefaultValueResolver; use Invoker\ParameterResolver\ResolverChain; $invoker = new Invoker( new ResolverChain([ new TypeHintContainerResolver($container), new DefaultValueResolver(), ]), ); $orchestrator = new Orchestrator( storage: new JsonFileStorage(__DIR__ . '/storage/migrations.json'), finder: new DirectoryFinder(__DIR__ . '/migrations'), executor: new Executor(new PhpDiInvoker($invoker)), );
use Dakujem\Migrun\Executor\InvokesCallable; use Dakujem\Wire\Invoker as GenieInvoker; final readonly class WireGenieInvoker implements InvokesCallable { public function __construct(private GenieInvoker $invoker) {} public function invoke(callable $fn): mixed { return $this->invoker->invoke($fn); } }
Usage:
use Dakujem\Wire\Genie; $orchestrator = new Orchestrator( storage: new JsonFileStorage(__DIR__ . '/storage/migrations.json'), finder: new DirectoryFinder(__DIR__ . '/migrations'), executor: new Executor(new WireGenieInvoker(new Genie($container))), );
Custom executor
Implement ExecutesMigrations to wrap each migration in a transaction, add logging, emit events, etc.:
use Dakujem\Migrun\Direction; use Dakujem\Migrun\ExecutesMigrations; use Dakujem\Migrun\Executor\Executor; use Dakujem\Migrun\MigrationFile; final class TransactionalExecutor implements ExecutesMigrations { public function __construct( private Executor $inner, private \PDO $db, ) {} public function execute(MigrationFile $migration, Direction $direction): void { $this->db->beginTransaction(); try { $this->inner->execute($migration, $direction); $this->db->commit(); } catch (\Throwable $e) { $this->db->rollBack(); throw $e; } } }
Concurrency and locking
Migrun ships no locking.
If two migration runs can overlap โ two deploys racing, a
CI job and a manual run, two web nodes booting at once โ you should serialize them
yourself. This section shows how, in a few lines of your own code.
Lock the whole runner, not each migration
Orchestrator::run() loops over migrations doing check-then-act: it asks storage
isApplied(), executes the migration, then records it. Across two processes that is a
TOCTOU race โ both can
see the same migration as pending and both run it. Locking each migration
individually does not fix this (the gap between check and lock still races, and
interleaving two runs violates the ordering that later migrations depend on).
A single lock around the entire run makes the whole check-execute-record sequence atomic and preserves order. It is both the correct granularity and the simpler one.
The RunsMigrations seam
Orchestrator implements the RunsMigrations interface (run(), rollback(),
status()). Type against it and you can wrap the runner transparently โ for locking,
but equally for logging, timing, or event emission:
use Dakujem\Migrun\RunsMigrations;
If your decorator needs to forward a reporter for live progress, type it and its inner runner against
RunsMigrationsWithReporterinstead โ see Passing a reporter through an abstraction. The decorator below keeps the plain contract, since it forwards no reporter.
A minimal mutex contract
Define a tiny lock abstraction. A withLock(callable) shape (rather than
separate acquire/release calls) guarantees the lock is released even if a migration
throws โ mirroring how InvokesCallable::invoke() works in this library:
interface Mutex { /** Run $critical while holding an exclusive lock; release on return or throw. */ public function withLock(callable $critical): mixed; } final class CouldNotAcquireLock extends \RuntimeException {}
A locking decorator
Wrap the runner. Only run() and rollback() mutate โ status() is read-only and
passes through unlocked:
use Dakujem\Migrun\RunsMigrations; final readonly class LockingOrchestrator implements RunsMigrations { public function __construct( private RunsMigrations $inner, private Mutex $mutex, ) {} public function run(): array { return $this->mutex->withLock(fn() => $this->inner->run()); } public function rollback(int $steps = 1): array { return $this->mutex->withLock(fn() => $this->inner->rollback($steps)); } public function status(): array { return $this->inner->status(); } }
Example: file lock (flock)
Good for single-host setups and the JSON/SQLite storage backends. The OS releases the lock automatically if the process dies, so a crash cannot strand it:
final class FlockMutex implements Mutex { /** @param bool $wait true = block until the lock is free; false = fail fast. */ public function __construct( private string $lockFile, private bool $wait = true, ) {} public function withLock(callable $critical): mixed { $handle = fopen($this->lockFile, 'c'); if ($handle === false) { throw new CouldNotAcquireLock("Cannot open lock file: {$this->lockFile}"); } $flags = LOCK_EX | ($this->wait ? 0 : LOCK_NB); if (!flock($handle, $flags)) { fclose($handle); throw new CouldNotAcquireLock("Another migration run holds the lock: {$this->lockFile}"); } try { return $critical(); } finally { flock($handle, LOCK_UN); fclose($handle); } } }
Example: database advisory lock (PDO)
For multi-host setups, a database advisory lock coordinates every node through the database itself. Advisory locks are session-scoped, so the mutex must reuse the same PDO connection that runs the migrations (and, ideally, the storage). The lock is released automatically if the connection drops:
// MySQL / MariaDB โ GET_LOCK / RELEASE_LOCK final class PdoAdvisoryMutex implements Mutex { public function __construct( private \PDO $pdo, // the SAME connection used to run migrations private string $name = 'migrun', private int $timeout = 10, // seconds to wait before giving up ) {} public function withLock(callable $critical): mixed { $acquire = $this->pdo->prepare('SELECT GET_LOCK(?, ?)'); $acquire->execute([$this->name, $this->timeout]); if ((string) $acquire->fetchColumn() !== '1') { throw new CouldNotAcquireLock("Timed out acquiring advisory lock: {$this->name}"); } try { return $critical(); } finally { $this->pdo->prepare('SELECT RELEASE_LOCK(?)')->execute([$this->name]); } } }
For PostgreSQL, swap the SQL for session advisory locks:
SELECT pg_advisory_lock(hashtext(?)) to acquire and
SELECT pg_advisory_unlock(hashtext(?)) to release (Postgres keys are integers, so
hash the name). SQLite has no advisory-lock function โ use FlockMutex on the
database file instead.
Trade-offs
- Wait vs. fail fast. CLI/deploy runs usually want to wait (block with a timeout) so a racing run queues instead of erroring; a CI gate may prefer to fail fast. Make it a constructor flag on the concrete mutex, as shown above.
- Crash safety.
flockand database advisory locks auto-release when the process or connection dies. Avoid a "lock row" design (INSERT a sentinel row, delete it at the end) โ if the process is killed mid-run the row is stranded and needs manual or TTL-based cleanup. - Same connection for advisory locks. Because
GET_LOCK/pg_advisory_lockare bound to the session,PdoAdvisoryMutexmust share the connection that executes the migrations. The recommended single-$pdosetup already satisfies this.
Wiring it up
$orchestrator = (new MigrunBuilder()) ->directory(__DIR__ . '/migrations') ->pdoStorage($pdo) ->container($container) ->build(); // Wrap with the lock of your choice: $runner = new LockingOrchestrator($orchestrator, new PdoAdvisoryMutex($pdo)); // or, for single-host / file storage: // $runner = new LockingOrchestrator($orchestrator, new FlockMutex(__DIR__ . '/migrations/.migrun/migrun.lock')); $runner->run();
$runner is a RunsMigrations, so it drops straight into the CLI script or Symfony
command shown earlier in place of the bare Orchestrator.
examples/migrate.php shows this wired into a working script โ
the mutex, the decorator, and a distinct exit code so a caller can tell lock contention
apart from a failed migration.
Seeders
Because an Orchestrator is just a directory + storage + invoker, you can run a second one for database seeders with no extra infrastructure:
$migrations = (new MigrunBuilder()) ->directory(__DIR__ . '/migrations') ->pdoStorage($pdo) // table: migrun_migrations ->container($container) ->build(); $seeders = (new MigrunBuilder()) ->directory(__DIR__ . '/seeds') ->pdoStorage($pdo, table: 'migrun_seeds') // separate table, same database ->container($container) ->build(); $migrations->run(); $seeders->run();
Seeds are tracked independently of migrations โ running one never affects the other's history.
Migrating between storage backends
If you need to switch storage backends (e.g. from the default JsonFileStorage to PdoStorage),
use the storage API to transfer the history.
Read all applied migrations from the old backend in reverse order (oldest first), then mark them applied in the new one:
use Dakujem\Migrun\Storage\JsonFileStorage; use Dakujem\Migrun\Storage\PdoStorage; $old = new JsonFileStorage(__DIR__ . '/migrations/.migrun/migrun.json'); $new = new PdoStorage($pdo); $applied = $old->getApplied(); // newest first $migrationOrder = array_reverse( // oldest first is_array($applied) ? $applied : iterator_to_array($applied), ); foreach ($migrationOrder as $entry) { $new->markApplied($entry->id(), $entry->at()); }
This works for any combination of backends.
Switching between
PdoStorageandMysqliStoragerequires no data migration โ both adapters use the same table schema (id,applied_at). Point the new adapter at the existing table and it works immediately.