πŸ”„ Migration

Info: Since version 1.3

Note: Migrations are part of the hectororm/migration package. You can find it on Packagist. See also: Schema introspection and Plan.

The Migration package orchestrates database migrations for Hector ORM. It builds on top of the Plan system to provide a complete workflow: define migrations as PHP classes, track which ones have been applied, and run them forward or backward with transaction safety.


Quick start

use Hector\Connection\Connection;
use Hector\Migration\MigrationRunner;
use Hector\Migration\Provider\DirectoryProvider;
use Hector\Migration\Tracker\DbTracker;
use Hector\Schema\Plan\Compiler\AutoCompiler;

$connection = new Connection('mysql:host=localhost;dbname=mydb', 'root', 'secret');
$compiler = new AutoCompiler($connection);

$provider = new DirectoryProvider(__DIR__ . '/migrations');
$tracker = new DbTracker($connection);

$runner = new MigrationRunner($provider, $tracker, $compiler, $connection);

// Apply all pending migrations
$applied = $runner->up();
// Returns: ['20260101000000_CreateUsers', '20260302143000_AddPosts', ...]

Writing migrations

A migration is a PHP class that implements MigrationInterface. It receives a Plan object and describes the DDL operations to apply.

Basic migration (one-way)

use Hector\Migration\MigrationInterface;
use Hector\Schema\Plan\Plan;

class AddAvatarColumn implements MigrationInterface
{
    public function up(Plan $plan): void
    {
        $plan->alter('users', function ($table) {
            $table->addColumn('avatar', 'VARCHAR(255)', nullable: true);
        });
    }
}

Reversible migration

Implement ReversibleMigrationInterface to support rollback:

use Hector\Migration\ReversibleMigrationInterface;
use Hector\Schema\Index;
use Hector\Schema\Plan\Plan;

class CreateUsersTable implements ReversibleMigrationInterface
{
    public function up(Plan $plan): void
    {
        $plan->create('users', function ($table) {
            $table->addColumn('id', 'INT', autoIncrement: true);
            $table->addColumn('email', 'VARCHAR(255)');
            $table->addColumn('name', 'VARCHAR(100)', nullable: true);
            $table->addIndex('PRIMARY', ['id'], Index::PRIMARY);
            $table->addIndex('idx_email', ['email'], Index::UNIQUE);
        });
    }

    public function down(Plan $plan): void
    {
        $plan->drop('users');
    }
}

Warning: The runner will throw a MigrationException if you try to revert a migration that does not implement ReversibleMigrationInterface.

Using raw SQL

For SQL features not covered by the Plan API, use $plan->raw() to inject raw SQL statements. See the Plan documentation for details.

class AddFulltextSearch implements MigrationInterface
{
    public function up(Plan $plan): void
    {
        $plan->alter('articles', function ($table) {
            $table->addColumn('title', 'VARCHAR(255)');
            $table->addColumn('body', 'TEXT');
        });

        // Fulltext index β€” not supported by the Plan API
        $plan->raw('CREATE FULLTEXT INDEX ft_search ON articles (title, body)');
    }
}

The #[Migration] Attribute

You can annotate migration classes with the #[Migration] attribute to add a human-readable description:

use Hector\Migration\Attributes\Migration;
use Hector\Migration\ReversibleMigrationInterface;
use Hector\Schema\Index;
use Hector\Schema\Plan\Plan;

#[Migration(description: 'Create the users table')]
class CreateUsersTable implements ReversibleMigrationInterface
{
    public function up(Plan $plan): void
    {
        $plan->create('users', function ($table) {
            $table->addColumn('id', 'INT', autoIncrement: true);
            $table->addColumn('email', 'VARCHAR(255)');
            $table->addColumn('name', 'VARCHAR(100)', nullable: true);
            $table->addIndex('PRIMARY', ['id'], Index::PRIMARY);
            $table->addIndex('idx_email', ['email'], Index::UNIQUE);
        });
    }

    public function down(Plan $plan): void
    {
        $plan->drop('users');
    }
}
Parameter Type Default Description
$description ?string null Human-readable description, used in log messages

The attribute is optional. Migrations without it work normally. When present, the description is included in log messages: Applying migration "CreateUsers" (Create the users table)...


Providers

Providers supply the list of migrations. All providers implement MigrationProviderInterface (IteratorAggregate, Countable, getArrayCopy()).

ArrayProvider

Register migrations manually:

use Hector\Migration\Provider\ArrayProvider;

$provider = new ArrayProvider([
    'create_users' => new CreateUsersTable(),
    'add_posts' => new AddPostsTable(),
]);

// Or add incrementally (migration first, optional ID second)
$provider = new ArrayProvider();
$provider->add(new CreateUsersTable(), 'create_users');
$provider->add(new AddPostsTable());  // ID defaults to FQCN

Duplicate migration identifiers throw a MigrationException.

DirectoryProvider

Scan a directory for migration files. Each file must return a MigrationInterface instance or a FQCN string. The migration identifier is the relative file path without extension, with directory separators normalized to /:

use Hector\Migration\Provider\DirectoryProvider;

$provider = new DirectoryProvider(
    directory: __DIR__ . '/migrations',
    pattern: '*.php',  // glob pattern (default)
    depth: 0,          // 0 = flat (default), -1 = unlimited, N = max levels
);
Parameter Type Default Description
$directory string (req.) Path to the migrations directory
$pattern string '*.php' Glob pattern for matching files
$depth int 0 Recursive scanning depth
$container ?ContainerInterface null PSR-11 container for instantiation

The $depth parameter controls recursive directory scanning:

  • 0 β€” scan only the top-level directory (default)
  • -1 β€” scan all subdirectories recursively (unlimited)
  • N β€” scan up to N levels of subdirectories

Migration files must return either an instance or a fully qualified class name:

// migrations/20260101000000_CreateUsers.php

// Option 1: return an instance (supports anonymous classes)
return new class implements ReversibleMigrationInterface {
    public function up(Plan $plan): void { /* ... */ }
    public function down(Plan $plan): void { /* ... */ }
};

// Option 2: return a FQCN string
return CreateUsersTable::class;

Files that don’t match the glob pattern are silently ignored. Files that match but return an invalid value (neither a MigrationInterface instance nor a valid FQCN string) throw a MigrationException. Duplicate identifiers also throw a MigrationException.

Dependency injection via container

When a file returns a class name, the DirectoryProvider can resolve it through a PSR-11 container:

use Hector\Migration\Provider\DirectoryProvider;

$provider = new DirectoryProvider(
    directory: __DIR__ . '/migrations',
    container: $container, // ?ContainerInterface (PSR-11)
);

If the container has the class, it is resolved from the container (enabling constructor injection). Otherwise, the class is instantiated directly with new.

Note: The psr/container package is suggested, not required.

Psr4Provider

Load migration classes using PSR-4 autoloading conventions. Migration classes are discovered by scanning a directory and mapping file paths to fully qualified class names:

use Hector\Migration\Provider\Psr4Provider;

$provider = new Psr4Provider(
    namespace: 'App\\Migration',
    directory: __DIR__ . '/src/Migration',
    depth: -1,  // unlimited (default for PSR-4)
);
Parameter Type Default Description
$namespace string (req.) Base PSR-4 namespace
$directory string (req.) Directory mapped to the namespace
$pattern string '*.php' Glob pattern for matching files
$depth int -1 Recursive scanning depth (unlimited)
$container ?ContainerInterface null PSR-11 container for instantiation

How it works:

  1. Scans $directory for files matching $pattern (respecting $depth)
  2. Deduces the FQCN using PSR-4 convention: $namespace + relative path
  3. Skips files whose class does not implement MigrationInterface
  4. Uses the FQCN as the migration identifier (e.g. App\Migration\CreateUsers)
  5. Migrations are sorted alphabetically by FQCN

Example directory structure

src/Migration/
β”œβ”€β”€ CreateUsers.php             β†’ App\Migration\CreateUsers
β”œβ”€β”€ AddPosts.php                β†’ App\Migration\AddPosts
└── V2/
    └── AddComments.php         β†’ App\Migration\V2\AddComments

Note: Unlike DirectoryProvider, the Psr4Provider relies on Composer autoloading β€” classes are not required manually. Make sure the namespace is registered in your composer.json autoload section.


Trackers

Trackers record which migrations have been applied. All trackers implement MigrationTrackerInterface (IteratorAggregate, Countable, getArrayCopy()).

Method Description
getArrayCopy(): string[] Get all applied migration identifiers
isApplied(string $migrationId): bool Check if a migration has been applied
markApplied(string $migrationId, ?float $durationMs): void Mark a migration as applied
markReverted(string $migrationId): void Mark a migration as reverted

The $durationMs parameter on markApplied() records the execution duration in milliseconds. It is passed automatically by the runner.

DbTracker

Stores applied migrations in a database table (created automatically). Uses the QueryBuilder internally for SELECT, INSERT and DELETE operations:

use Hector\Migration\Tracker\DbTracker;

$tracker = new DbTracker(
    connection: $connection,
    tableName: 'hector_migrations', // default
);

The table has three columns:

  • migration_id β€” VARCHAR(255), primary key
  • applied_at β€” DATETIME
  • duration_ms β€” FLOAT, nullable (execution time in milliseconds)

The table is created with CREATE TABLE IF NOT EXISTS on first use.

Note: DbTracker requires the hectororm/query package (composer require hectororm/query).

FileTracker

Stores applied migrations in a local JSON file:

use Hector\Migration\Tracker\FileTracker;

$tracker = new FileTracker(
    filePath: __DIR__ . '/.hector.migrations.json',
);
Parameter Type Default Description
$filePath string (req.) Path to the JSON tracking file
$lock bool true Use exclusive lock when writing (disable for NFS)

The file is created on first write and contains a JSON object indexed by migration identifier:

{
    "20260101000000_CreateUsers": {
        "applied_at": "2026-01-15T10:30:00+00:00",
        "duration_ms": 42.57
    }
}

FlysystemTracker

Same as FileTracker but uses League Flysystem for storage β€” useful for remote or cloud-based tracking:

use Hector\Migration\Tracker\FlysystemTracker;
use League\Flysystem\Filesystem;
use League\Flysystem\Local\LocalFilesystemAdapter;

$filesystem = new Filesystem(new LocalFilesystemAdapter('/path'));
$tracker = new FlysystemTracker(
    filesystem: $filesystem,
    filePath: '.hector.migrations.json', // default
);
Parameter Type Default Description
$filesystem FilesystemOperator (req.) Flysystem filesystem instance
$filePath string '.hector.migrations.json' Path within the filesystem

Note: The league/flysystem package is suggested, not required.

ChainTracker

Aggregate multiple trackers with a configurable strategy:

use Hector\Migration\Tracker\ChainTracker;
use Hector\Migration\Tracker\ChainStrategy;

$tracker = new ChainTracker(
    [$databaseTracker, $fileTracker],
    ChainStrategy::ANY,
);

Write operations (markApplied, markReverted) are always propagated to all trackers.

Read operations (isApplied, getArrayCopy) depend on the strategy:

Strategy isApplied() returns true if… getArrayCopy() returns…
ChainStrategy::ANY any tracker reports it applied Union of all trackers
ChainStrategy::ALL all trackers report it applied Intersection of all trackers
ChainStrategy::FIRST the first tracker reports it applied Only the first tracker’s data

MigrationRunner

The MigrationRunner orchestrates the full migration lifecycle:

use Hector\Migration\MigrationRunner;

$runner = new MigrationRunner(
    provider: $provider,
    tracker: $tracker,
    compiler: $compiler,
    connection: $connection,
    schema: $schema,            // optional β€” enables schema introspection in Plan compilers
    logger: $logger,            // optional β€” PSR-3 LoggerInterface
    eventDispatcher: $dispatcher, // optional β€” PSR-14 EventDispatcherInterface
);

Constructor parameters

Parameter Type Default Description
$provider MigrationProviderInterface (required) Migration source
$tracker MigrationTrackerInterface (required) Tracks applied migrations
$compiler CompilerInterface (required) Compiles Plan operations into SQL
$connection Connection (required) Database connection for executing statements
$schema ?Schema null Enables schema introspection in Plan compilers
$logger ?LoggerInterface null PSR-3 logger for migration lifecycle
$eventDispatcher ?EventDispatcherInterface null PSR-14 event dispatcher

Runner methods

Method Description
up(?int $steps = null, bool $dryRun = false): string[] Apply pending migrations (null = all)
down(int $steps = 1, bool $dryRun = false): string[] Revert applied migrations
getPending(): array<string, MigrationInterface> Get migrations not yet applied
getApplied(): array<string, MigrationInterface> Get migrations already applied
getStatus(): array<string, bool> Get status of all migrations

Running migrations

// Apply ALL pending migrations
$applied = $runner->up();

// Apply at most 3 pending migrations
$applied = $runner->up(steps: 3);

// Revert the last applied migration
$reverted = $runner->down();

// Revert the last 5 applied migrations
$reverted = $runner->down(steps: 5);

Both up() and down() return an array of migration identifiers that were applied or reverted.

Querying status

// Migrations not yet applied
$pending = $runner->getPending();
// Returns: ['20260302143000_AddPosts' => AddPostsTable, ...]

// Migrations already applied
$applied = $runner->getApplied();
// Returns: ['20260101000000_CreateUsers' => CreateUsersTable, ...]

// Full status map
$status = $runner->getStatus();
// Returns: ['20260101000000_CreateUsers' => true, '20260302143000_AddPosts' => false, ...]

Transaction safety

Each migration is executed within a database transaction:

  1. beginTransaction()
  2. Execute all Plan statements
  3. commit()
  4. Mark applied/reverted in the tracker

If any statement fails, the transaction is rolled back and a MigrationException is thrown with the original error as previous. The tracker is not updated on failure.

Note: Migrations that produce an empty Plan (no DDL operations) are still tracked β€” they are simply marked as applied/reverted without opening a transaction.

Dry-run mode

Both up() and down() accept a dryRun parameter. In dry-run mode, the runner goes through the full migration flow β€” building the plan, compiling SQL, dispatching events β€” but does not execute the statements, open transactions, or update the tracker:

// Preview which SQL would be generated
$applied = $runner->up(dryRun: true);

// Also works with down
$reverted = $runner->down(steps: 2, dryRun: true);

In dry-run mode:

  • SQL statements are logged with a [DRY-RUN] prefix
  • Events are dispatched normally (listeners can inspect the plan)
  • No database changes are made
  • No tracker updates are recorded
  • The return value is the list of migration identifiers that would be applied/reverted

Logging (PSR-3)

The runner accepts an optional PSR-3 LoggerInterface to log the migration lifecycle:

Level When Message example
info Before executing a migration Applying migration "CreateUsers" (Create the users table)...
info After a successful migration Migration "CreateUsers" applied successfully (42.57ms)
debug For each SQL statement [CreateUsers] CREATE TABLE ...
debug Migration skipped by event listener Migration "..." skipped by event listener
error On migration failure Migration "..." failed during up: ...

When a migration class has a #[Migration(description: '...')] attribute, the description is included in log messages. Without the attribute, only the migration identifier is shown.

In dry-run mode, all log messages are prefixed with [DRY-RUN].

use Psr\Log\LoggerInterface;

$runner = new MigrationRunner(
    provider: $provider,
    tracker: $tracker,
    compiler: $compiler,
    connection: $connection,
    logger: $logger, // any PSR-3 logger
);

Events (PSR-14)

The runner dispatches PSR-14 events around each migration. If no event dispatcher is provided, events are simply not dispatched (zero overhead thanks to the null-safe ?-> operator).

Event classes

All event classes live in Hector\Migration\Event\.

Event When dispatched Stoppable? Extra data
MigrationBeforeEvent Before executing a migration Yes migration ID, direction
MigrationAfterEvent After a successful migration No + durationMs (execution time)
MigrationFailedEvent After a failure (post-rollback) No + exception + durationMs

All events carry: getMigrationId(), getMigration(), getDirection(), getTime(), isDryRun().

The direction is a string value from the Direction class constants: Direction::UP or Direction::DOWN.

Stoppable before events

If a listener stops a MigrationBeforeEvent, the migration is skipped entirely β€” not executed and not tracked:

use Hector\Migration\Event\MigrationBeforeEvent;

// Example: skip a specific migration
$listener = function (MigrationBeforeEvent $event): void {
    if ($event->getMigrationId() === '20260101000000_CreateUsers') {
        $event->stopPropagation();
    }
};

This enables use cases like: conditional filtering or interactive confirmation.

After and failed events

The MigrationAfterEvent carries the execution duration:

use Hector\Migration\Event\MigrationAfterEvent;

$listener = function (MigrationAfterEvent $event): void {
    $durationMs = $event->getDurationMs(); // float|null
    // Audit, metrics, etc.
};

The MigrationFailedEvent carries the original exception and the duration until failure:

use Hector\Migration\Event\MigrationFailedEvent;

$listener = function (MigrationFailedEvent $event): void {
    $exception = $event->getException();  // Throwable
    $durationMs = $event->getDurationMs(); // float|null
};

Complete example

use Hector\Connection\Connection;
use Hector\Migration\MigrationRunner;
use Hector\Migration\Provider\DirectoryProvider;
use Hector\Migration\Tracker\DbTracker;
use Hector\Schema\Generator\MySQL as MySQLGenerator;
use Hector\Schema\Plan\Compiler\AutoCompiler;

// Setup
$connection = new Connection('mysql:host=localhost;dbname=mydb', 'root', 'secret');
$compiler = new AutoCompiler($connection);
$generator = new MySQLGenerator($connection);
$schema = $generator->generateSchema('mydb');

// Provider: scan migrations directory
$provider = new DirectoryProvider(
    directory: __DIR__ . '/migrations',
);

// Tracker: store status in the database
$tracker = new DbTracker($connection);

// Runner
$runner = new MigrationRunner($provider, $tracker, $compiler, $connection, $schema);

// Check what needs to be done
echo "Pending: " . count($runner->getPending()) . "\n";
echo "Applied: " . count($runner->getApplied()) . "\n";

foreach ($runner->getStatus() as $id => $applied) {
    echo sprintf("  [%s] %s\n", $applied ? 'x' : ' ', $id);
}

// Preview with dry-run
$runner->up(dryRun: true);

// Apply all pending migrations
$applied = $runner->up();
echo "Applied " . count($applied) . " migration(s)\n";

// Revert the last one
$reverted = $runner->down();
echo "Reverted: " . implode(', ', $reverted) . "\n";

Example migration file

// migrations/20260101000000_CreateUsers.php

use Hector\Migration\ReversibleMigrationInterface;
use Hector\Schema\Index;
use Hector\Schema\Plan\Plan;

return new class implements ReversibleMigrationInterface {
    public function up(Plan $plan): void
    {
        $plan->create('users', function ($table) {
            $table->addColumn('id', 'INT', autoIncrement: true);
            $table->addColumn('email', 'VARCHAR(255)');
            $table->addColumn('name', 'VARCHAR(100)', nullable: true);
            $table->addColumn('created_at', 'DATETIME', default: 'CURRENT_TIMESTAMP', hasDefault: true);
            $table->addIndex('PRIMARY', ['id'], Index::PRIMARY);
            $table->addIndex('idx_email', ['email'], Index::UNIQUE);
        });
    }

    public function down(Plan $plan): void
    {
        $plan->drop('users');
    }
};

Tip: Anonymous classes (new class implements ...) work great for migration files β€” they keep things self-contained and avoid polluting the autoloader.


Installation

composer require hectororm/migration

Optional dependencies:

# For DbTracker (database-backed tracking)
composer require hectororm/query

# For FlysystemTracker
composer require league/flysystem

# For container-based migration instantiation
composer require psr/container

# For migration lifecycle logging
composer require psr/log

# For migration lifecycle events
composer require psr/event-dispatcher

See also

  • Schema β€” database introspection and metadata
  • Plan β€” DDL operations builder and SQL compiler

Last updated: Wed, 18 Mar 2026 16:03