wilycode / migration
Small, driver-independent PHP database migration runner with CLI, version tracking, and rollback.
Requires
- php: ^8.4
- ext-ctype: *
- wilycode/database: ^0.1
Requires (Dev)
- phpunit/phpunit: ^11.0
- wilycode/database-driver-pdo: ^0.1
Suggests
- wilycode/database-driver-mysqli: MySQLi adapter for MySQL or MariaDB
- wilycode/database-driver-pdo: PDO adapter for database connections
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-10-19 00:01:57 UTC
README
A lightweight, framework-independent database migration runner for PHP 8.4+. Discover versioned PHP migrations, apply pending changes, inspect migration status, and roll back batches with a WilyCode database driver.
WilyCode PHP Ecosystem
Part of WilyCode — a collection of independent, reusable PHP packages. Each component has its own repository, versioning, and Composer installation.
Contents
- Features
- Requirements and installation
- Database configuration
- Quick start
- Creating migrations
- Command-line interface
- API reference
- Transactions and safety
- SQL file limitations
- Migration history and versions
- Locking and concurrency
- File loading and generation
- Development and testing
- Contributing and license
Features
- Versioned PHP migrations based on timestamps in filenames.
up(),down(), andstatus()APIs, plus a Composer-installed CLI.- Migration generator with optional
.up.sqland.down.sqlcompanion files. - Configurable migration history table with batch tracking.
- Optional per-migration transactions, where supported by the database and SQL statements.
- Driver-independent core, with separately installed PDO and MySQLi adapters.
- File-based locking to reduce concurrent execution on a single host.
Requirements and installation
- PHP 8.4+.
- A WilyCode database driver contract and one compatible adapter (PDO or MySQLi).
- A database that supports
CREATE TABLE IF NOT EXISTS, SQL placeholders, and the statements used by your migrations.
Install the migration package and a PDO adapter:
composer require wilycode/migration composer require wilycode/database-driver-pdo
You can use wilycode/database-driver-mysqli instead of PDO if your application uses MySQLi.
Package naming: This library requires
wilycode/databasefor the shared driver contract. The PDO and MySQLi adapters currently install from Packagist underwilycode/database-driver-pdoandwilycode/database-driver-mysqli, even though their code repositories are now database-pdo and database-mysqli. Do not explicitly require the supersededwilycode/database-driverpackage together withwilycode/database, as both contain the same PHP contracts. Switch adapter package names only after the new names are published and verified on Packagist.
Database configuration
Use environment variables for the entire DSN, username, and password. The examples below expect all three variables to be defined; they intentionally do not provide fallback credentials.
For example, in a Unix-like shell:
export DB_DSN='mysql:host=127.0.0.1;dbname=app;charset=utf8mb4' export DB_USER='app' export DB_PASSWORD='your-password'
The PDO connection can then be created with:
$db = PdoDatabase::connect( getenv('DB_DSN'), getenv('DB_USER'), getenv('DB_PASSWORD'), );
Important: getenv() reads the process environment; it does not load a .env file automatically. If you use a .env loader, run it before creating the connection. Unset variables make getenv() return false, so ensure that the variables are provided by the application or deployment environment. Never commit real database credentials or a populated .env file.
Quick start
Create a PHP entry point in your application and a database/migrations/ directory:
<?php declare(strict_types=1); require __DIR__ . '/vendor/autoload.php'; use WilyCode\Database\DriverPdo\PdoDatabase; use WilyCode\Migration\DatabaseMigrationRepository; use WilyCode\Migration\Migrator; $db = PdoDatabase::connect( getenv('DB_DSN'), getenv('DB_USER'), getenv('DB_PASSWORD'), ); $repository = new DatabaseMigrationRepository($db); $migrator = Migrator::fromPhpDirectory( db: $db, repository: $repository, migrationsDir: __DIR__ . '/database/migrations', ); print_r($migrator->status()); // Inspect available and applied migrations. $report = $migrator->up(); // Apply pending migrations as one batch. print_r($report->applied); // list<string> // Roll back the last applied batch (destructive): // $report = $migrator->down();
Use the same $db instance for the migrator and its DatabaseMigrationRepository. See Transactions and safety for restrictions on schema changes and transaction handling.
Creating migrations
Create a file such as database/migrations/20261016_120000_create_users_table.php:
<?php declare(strict_types=1); use WilyCode\Database\Driver\DatabaseInterface; use WilyCode\Migration\AbstractMigration; return new class extends AbstractMigration { // MySQL/MariaDB DDL implicitly commits transactions. public function useTransaction(): bool { return false; } public function up(DatabaseInterface $db): void { $this->execSql($db, 'CREATE TABLE users (id INT PRIMARY KEY, name VARCHAR(100) NOT NULL)'); } public function down(DatabaseInterface $db): void { $this->execSql($db, 'DROP TABLE users'); } };
The filename defines version 20261016_120000 and the default description Create users table. Override description(): ?string if you need a custom description.
Each PHP migration file must return a new AbstractMigration instance. The loader executes migration files with require; only load trusted files. The filename format is YYYYMMDD_HHMMSS[_description].php, and each timestamp must be unique.
To execute a sibling SQL file from a PHP migration, call execSqlFile($db, 'my_migration.up.sql'). Relative SQL paths resolve against the PHP migration file's directory. See SQL file limitations before using complex SQL.
Command-line interface
The CLI is available after Composer installation as vendor/bin/wilycode-migrate (on Windows: vendor\bin\wilycode-migrate). Generate migrations with:
vendor/bin/wilycode-migrate make "Create users table" --dir=database/migrations vendor/bin/wilycode-migrate make "Insert initial data" --dir=database/migrations --with-sql
Create an application bootstrap file named migrate.php:
<?php declare(strict_types=1); require __DIR__ . '/vendor/autoload.php'; use WilyCode\Database\DriverPdo\PdoDatabase; use WilyCode\Migration\DatabaseMigrationRepository; $db = PdoDatabase::connect( getenv('DB_DSN'), getenv('DB_USER'), getenv('DB_PASSWORD'), ); return [ 'db' => $db, 'repo' => new DatabaseMigrationRepository($db, 'migrations'), 'dir' => 'database/migrations', // Relative to this bootstrap file. 'recursive' => false, 'useTransactions' => true, ];
Set the environment variables as described in Database configuration, then run:
vendor/bin/wilycode-migrate status --bootstrap=migrate.php vendor/bin/wilycode-migrate up --bootstrap=migrate.php vendor/bin/wilycode-migrate up --bootstrap=migrate.php --to=20261016_120000 vendor/bin/wilycode-migrate down --bootstrap=migrate.php --batches=1
| Option | Behavior |
|---|---|
--bootstrap=migrate.php |
Load the application's migration configuration. Relative bootstrap paths resolve from the current working directory. |
WILYCODE_MIGRATE_BOOTSTRAP |
Environment-variable alternative to --bootstrap. Without either, the CLI looks for migrate.php in the current working directory. |
--dir=... |
Override the migration directory; relative paths resolve from the current working directory. Paths configured in the bootstrap resolve relative to the bootstrap file. |
--recursive |
Discover migrations in nested directories. |
--to=VERSION |
Apply pending migrations up to an existing version, inclusive. |
--batches=N / --step=N |
Roll back the last N batches, not individual files. |
--no-transactions |
Disable transaction wrapping for that run. |
Caution: down executes your migrations' rollback logic and may permanently remove data. Review the most recent batch and back up important databases before rollback.
API reference
| API | Description |
|---|---|
Migrator::fromPhpDirectory($db, $repository, $dir, $useTransactions = true, $recursive = false, $lock = null) |
Discover and sort migrations in a directory. |
$migrator->up(?string $toVersion = null): MigrationReport |
Apply pending migrations; an unknown target version throws. |
$migrator->down(int $batches = 1): MigrationReport |
Roll back the newest N batches, newest migration first. |
$migrator->status(): array |
Inspect available and applied migrations, including applied files missing from disk. |
(new MigrationMaker())->make($dir, $description, $withSqlFiles = false): string |
Generate a migration and optional SQL companion files. |
new DatabaseMigrationRepository($db, $table = 'migrations') |
Store applied migration versions and batch numbers. Creates the tracking table when needed. |
$migration->useTransaction(): bool |
Enable or disable transaction wrapping per migration. |
MigrationReport exposes read-only applied and rolledBack arrays of version strings and an isEmpty() method. You can provide your own repository by implementing MigrationRepositoryInterface.
Transaction defaults: Hand-written migrations extending AbstractMigration use true by default. Files generated by MigrationMaker explicitly use false by default to avoid accidental reliance on transactions for MySQL/MariaDB DDL.
Transactions and safety
- For transactional operations supported by the driver and database, migration SQL and its history record run within one transaction on the same driver instance. The built-in repository rejects a different driver instance.
- MySQL/MariaDB DDL may implicitly commit, even when the runner enables transactions. For schema changes (
CREATE TABLE,ALTER TABLE, etc.), preferuseTransaction(): boolreturningfalse. The runner cannot guarantee atomic schema changes. - When transactions are disabled, executing SQL and writing the migration history are separate operations; a failure can leave them out of sync.
up(),down(), andstatus()refuse to start inside an already active database transaction, before attempting to create the migration table. Run migrations outside transactions managed by your application.- When an operation fails, earlier migrations in the same run may already have completed. Review the status and database state before retrying.
- Never expose the CLI or migration bootstrap to untrusted users, and back up important data before applying destructive changes.
SQL file limitations
execSqlFile() uses a lightweight statement splitter, not a complete SQL dialect parser. It handles normal quoted strings, identifiers, common SQL comments, and CR/LF line endings, but rejects potentially ambiguous or unsupported constructions rather than silently splitting them incorrectly.
Unsupported examples include:
DELIMITERdirectives and PostgreSQLCOPY ... FROM STDINdata streams.- Dollar-quoted SQL blocks.
- Executable MySQL comments (
/*!...*/) and optimizer hints (/*+...*/). - Common
CREATE TRIGGER,PROCEDURE,FUNCTION, andEVENTstatements, includingCREATE DEFINER=...forms. - Ambiguous inline
#and--syntax: use a whole-line#comment or a--comment (with a space), when applicable.
PostgreSQL operators #>, #>>, and #- are preserved. Whole-line MySQL # comments and quoted text containing these markers are accepted.
An empty SQL statement, a blank/comment-only SQL file, or an unchanged generated SQL placeholder raises MigrationException: it is not recorded as a successful migration. Generated inline PHP migrations also throw LogicException until you replace their placeholder guards. If rollback intentionally requires no action, implement an empty down() method in PHP instead of leaving an empty .down.sql file.
For complex DDL, execute it from a PHP migration using a database-specific strategy rather than relying on the SQL file splitter. On nontransactional databases, multiple statements in an SQL file are not guaranteed to be atomic.
Migration history and versions
- Migration versions come from filename timestamps, not runtime setters. Two filenames with the same timestamp conflict even if their suffixes differ.
- Do not rename or edit already applied migrations. Renaming changes their identity: the original version will be reported as missing, and
up()will refuse to proceed. The migration journal does not store file checksums. up()rejects a target that has no matching migration file. It also refuses to run when a history entry refers to a missing file, or when a pending migration is older than the most recently applied version.status()reports applied migrations whose files are missing (missing: true,description: null); the CLI marks themMISSING. Rollback does not silently skip missing files.down()validates its history snapshot before running and checks that exactly one history row is removed for every rolled-back migration.- Invalid history rows fail preflight: versions must match
YYYYMMDD_HHMMSS, and batches must be positive integers. The next batch also cannot exceed 2,147,483,647, the portable limit of the history table's SQL integer column. DatabaseMigrationRepository::markApplied()requires theINSERToperation to report exactly one affected row; any other result is an error.
Restore missing files or reconcile migration history carefully before continuing. Do not rewrite deployed versions to make the runner accept inconsistent history.
Locking and concurrency
The default FileMigrationLock is non-blocking and scoped to the canonical migration directory. Discovery and loading take place under a lock; up(), down(), and status() acquire it again and refresh the file list before reading history. The lock is released on success or exception.
This protects against overlapping runs that use the same host, OS user, and migration directory. It is not a distributed lock: different hosts, isolated containers, users, or directories may still run concurrently. In multi-instance deployments, run migrations from a single deployment job or supply an appropriate distributed/database lock via MigrationLockInterface (the sixth argument to fromPhpDirectory()).
MigrationMaker::make() also acquires the directory lock during version selection and file generation. If another generator or migration operation holds the lock, it fails without writing files. Manual file changes outside the lock are not coordinated.
File loading and generation
- The migrator refreshes the directory for
status(),up(), anddown(), so newly added or removed files are detected. - Files already loaded by a migrator are cached. If a loaded file changes, that migrator refuses to continue rather than running stale code.
- After intentionally editing an unapplied anonymous-class migration, create a new migrator. After editing a named-class migration, restart the PHP process; named classes can otherwise cause declaration conflicts. Named migration instances must be cloneable and should remain stateless.
- Applied migration checksums are not stored, so a new migrator cannot prove that an already deployed file still matches its original content. Avoid editing applied files.
- The generator chooses a version later than the latest existing migration or SQL companion, including nested directories, and rejects invalid migration-looking filenames and timestamps.
Development and testing
composer install
composer validate --strict
composer test
The PHPUnit suite uses in-memory SQLite through PDO (pdo_sqlite). An additional disposable MySQL DDL test runs when WILYCODE_TEST_MYSQL_DSN, WILYCODE_TEST_MYSQL_USER, and WILYCODE_TEST_MYSQL_PASSWORD are configured. The documented GitHub Actions workflow runs both suites on PHP 8.4 with a MySQL 8.4 service and the pdo_sqlite / pdo_mysql extensions. Never run migration tests against production data.
Contributing and license
See CONTRIBUTING.md for development guidelines. MIT © 2026 Simon Piter. See LICENSE.