Search by

wilycode / migration

szymonpietrek-design

Small, driver-independent PHP database migration runner with CLI, version tracking, and rollback.

Package info

github.com/wilycode/migration

pkg:composer/wilycode/migration

Statistics

Installs: 1

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v0.1.0 2026-10-18 23:57 UTC

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.

Explore the WilyCode ecosystem →

Contents

Features

  • Versioned PHP migrations based on timestamps in filenames.
  • up(), down(), and status() APIs, plus a Composer-installed CLI.
  • Migration generator with optional .up.sql and .down.sql companion 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/database for the shared driver contract. The PDO and MySQLi adapters currently install from Packagist under wilycode/database-driver-pdo and wilycode/database-driver-mysqli, even though their code repositories are now database-pdo and database-mysqli. Do not explicitly require the superseded wilycode/database-driver package together with wilycode/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.), prefer useTransaction(): bool returning false. 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(), and status() 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:

  • DELIMITER directives and PostgreSQL COPY ... FROM STDIN data streams.
  • Dollar-quoted SQL blocks.
  • Executable MySQL comments (/*!...*/) and optimizer hints (/*+...*/).
  • Common CREATE TRIGGER, PROCEDURE, FUNCTION, and EVENT statements, including CREATE 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 them MISSING. 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 the INSERT operation 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(), and down(), 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.