Skip to content

Migrations

stellarwp/foundation-migrations manages the history of your application’s database schema. Each migration declares one change in up() and its inverse in down(). Foundation loads migration files, runs pending changes in order under a database lock, and records successful migrations.

Install the migrations runtime and development CLI. The runtime includes Foundation Database and the WP-CLI integration:

composer require stellarwp/foundation-migrations
composer require --dev stellarwp/foundation-cli

Add your project root to the existing root config.php. The Foundation CLI reads this file, and your application bootstrap supplies the same configuration to its container:

<?php declare(strict_types=1);

return [
	'foundation' => [
		'root'   => __DIR__,
		'prefix' => $_ENV['FOUNDATION_PREFIX'] ?? 'your-plugin',
	],
];

After creating the application container with that configuration, register providers in dependency order in src/App.php:

use StellarWP\Foundation\Container\Contracts\Provider;
use StellarWP\Foundation\Database\DatabaseProvider;
use StellarWP\Foundation\Migrations\MigrationsProvider;
use StellarWP\Foundation\WPCli\WPCliProvider;

/** @var list<class-string<Provider>> */
private const array PROVIDERS = [
	WPCliProvider::class,
	DatabaseProvider::class,
	MigrationsProvider::class,
];

DatabaseProvider supplies the shared connection. MigrationsProvider configures discovery, history, and migration execution; WPCliProvider enables wp <prefix> migrate. Applications that invoke Migrator programmatically can omit WPCliProvider. Register application providers afterward, before resolving migration services.

The default migration directory is db/migrations. Each PHP file returns an anonymous migration object. Migration files need no namespace or Composer autoload mapping; these examples use Plugin\\ mapped to src/ for the application table class. Keep migrations in the production archive even though the generator is a development dependency.

Create the table class and its first migration together:

vendor/bin/foundation make:database:table Reports \
    --table-name=your_plugin_reports \
    --migration

The command writes two files:

db/migrations/
└── 20260923000001_create_reports_table.php
src/Database/Tables/
└── Reports_Table.php

Your timestamp will differ. Reports_Table supplies the stable table name for application queries; Foundation adds the current WordPress site prefix. The migration supplies the schema. Generation writes PHP files; applying the migration creates the database table.

The generated up() already declares an auto-incrementing id. Add the columns your application needs. For this example, the completed file is db/migrations/20260923000001_create_reports_table.php:

<?php declare(strict_types=1);

use StellarWP\Foundation\Migrations\Migration;
use StellarWP\Foundation\Migrations\Schema\Blueprint;

return new class extends Migration {

	/**
	 * Create report storage.
	 */
	public function up( Blueprint $schema ): void {
		$table = $schema->create( 'your_plugin_reports' );

		$table->bigIncrements( 'id' );
		$table->string( 'title' );
		$table->string( 'status', 20 )->default( 'draft' );
		$table->index( 'status_lookup', 'status' );
	}

	/**
	 * Remove report storage and its data.
	 */
	public function down( Blueprint $schema ): void {
		$schema->drop( 'your_plugin_reports' );
	}
};

The filename supplies the persistent ID 20260923000001_create_reports_table. The migration owns its historical table name and schema. Foundation validates that unprefixed name and adds the active WordPress site prefix when planning the change. Refactoring or removing the application table class does not change this history.

wp your-plugin migrate --run --dry-run
wp your-plugin migrate --run
wp your-plugin migrate

Review the previewed SQL before applying it. --run applies all pending migrations; the last command shows their status. The first run creates the migration ledger automatically. your-plugin is the configured WP-CLI command prefix.

Foundation loads new migration files automatically from the configured directory. You do not add each migration to a provider list.

Generate a new migration for the existing table:

vendor/bin/foundation make:database:migration add_published_at --table=your_plugin_reports

This creates a timestamped file returning an anonymous migration. It selects the table in up() and leaves a placeholder for your changes. The argument names the migration; it does not generate column definitions.

Complete db/migrations/20260924000001_add_published_at.php like this:

<?php declare(strict_types=1);

use StellarWP\Foundation\Migrations\Migration;
use StellarWP\Foundation\Migrations\Schema\Blueprint;

return new class extends Migration {

	/**
	 * Allow reports to record their publication time.
	 */
	public function up( Blueprint $schema ): void {
		$table = $schema->table( 'your_plugin_reports' );

		$table->dateTime( 'published_at', 6 )->nullable();
	}

	/**
	 * Remove publication times when rolling back this change.
	 */
	public function down( Blueprint $schema ): void {
		$table = $schema->table( 'your_plugin_reports' );

		$table->dropColumn( 'published_at' );
	}
};

The generated down() initially throws IrreversibleMigration. Replace it with the inverse above only if deleting publication times is acceptable; remove its unused exception import and annotation. Otherwise, keep the exception. Forward migration still works, but rollback stops at this migration.

Leave the original migration and Reports_Table unchanged. Preview and apply the new migration with the same commands:

wp your-plugin migrate --run --dry-run
wp your-plugin migrate --run
wp your-plugin migrate

Existing reports have NULL in published_at until your application writes a value.

To reverse the highest applied migration, inspect its down() and run:

wp your-plugin migrate --rollback

In this example that removes published_at while retaining the reports table. Running --run afterward reapplies the column; values deleted by rollback do not return. Reversing the initial migration drops the complete reports table. Deployment commands cover targets, multiple reversals, and interrupted operations.

up() and down() declare schema changes. Foundation may replay them during planning, including previews. Keep queries and application work in the separate data callback.

Declare the complete replacement definition and call change():

$table = $schema->table( 'your_plugin_reports' );

$table->string( 'title', 255 )->change();

Restate any default, nullability, unsigned flag, comment, or other attribute to retain. For this example, a safe inverse can restore the original length only after accounting for values longer than that length.

$table = $schema->table( 'your_plugin_reports' );

$table->dropColumn( 'published_at' );

Removing a column deletes its values. Adding it again in down() restores its structure, not the deleted data. Keep IrreversibleMigration when no acceptable inverse exists.

$table = $schema->table( 'your_plugin_reports' );

$table->index( 'publication_lookup', 'published_at' );
$table->unique( 'report_title', 'title' );

Use dropIndex() for removal. To replace an index under the same name, declare both the removal and its replacement:

$table = $schema->table( 'your_plugin_reports' );

$table->dropIndex( 'status_lookup' );
$table->index( 'status_lookup', 'status', 'published_at' );

Use --create=your_plugin_reports to generate an initial migration for that unprefixed table name. Its up() declares the complete initial table, and its down() drops that table. Use --table=your_plugin_reports for a later alteration. The options are mutually exclusive.

Omitting both produces a generic migration with an empty up() and an irreversible down(). Declare the required schema changes using stable, unprefixed table names. Migration names alone never select table-dropping behavior.

Declaration Meaning
bigIncrements('id') Unsigned auto-incrementing BIGINT primary key
string('name', 191) VARCHAR with an explicit maximum length
text('body'), longText('body') TEXT or LONGTEXT
integer('count'), bigInteger('count') Integer columns
unsignedInteger('count'), unsignedBigInteger('count') Unsigned integers
boolean('active') Boolean storage
decimal('amount', 12, 4) Exact decimal precision and scale
binary('token', 16) VARBINARY with an explicit length
dateTime('updated_at', 6) DATETIME with fractional precision from 0 to 6

Columns support nullable(), notNull(), unsigned(), default(), comment(), and change(). Date/time declarations additionally support useCurrent() and useCurrentOnUpdate(). Use strings for exact decimal defaults. Table declarations support primary(...$columns) and comment().

up() and down() must be pure schema declarations. Foundation may replay them many times, including during previews. Do not query the live database, perform application work, or put existence guards in them.

An anonymous migration can implement MigratesData. Foundation supplies a DataMigrationContext to its data callback. Use quotedTable() to resolve a historical unprefixed name for the active site, and $context->db to run native Doctrine queries:

use StellarWP\Foundation\Migrations\Contracts\MigratesData;
use StellarWP\Foundation\Migrations\DataMigrationContext;
use StellarWP\Foundation\Migrations\Migration;
use StellarWP\Foundation\Migrations\Schema\Blueprint;

return new class extends Migration implements MigratesData {

	/**
	 * This migration changes data in an already-declared table.
	 */
	public function up( Blueprint $schema ): void {
	}

	/**
	 * Fill missing statuses without changing existing values.
	 */
	public function migrate( DataMigrationContext $context ): void {
		$table = $context->quotedTable( 'your_plugin_reports' );

		$context->db->executeStatement(
			'UPDATE ' . $table . ' SET status = ? WHERE status IS NULL',
			[ 'draft' ],
		);
	}
};

The context uses the same connection and naming policy as application tables. For operations that need an unquoted physical name, use $context->names->tableName( 'your_plugin_reports' ). Foundation supplies the context for each callback; consumers do not construct or retain it.

This example inherits the default irreversible down(): replacing data has no automatic safe inverse.

Foundation runs this callback after schema changes and before writing history. It must be safe to repeat if the process stops or recording fails. You may use the shared connection’s transactional() for a bounded data operation. Complete that transaction before returning; an open transaction interrupts the migration and is rolled back. DDL remains outside that transaction. Preview reports that a data callback exists but does not execute it. The data contract has no automatic reverse callback: supply schema rollback only when reversing remains safe for the resulting data, or throw IrreversibleMigration.

Programmatic installation or upgrade code injects Migrator and calls $migrator->migrate() at its chosen upgrade boundary. A public plugin should run this after WordPress and its providers are ready, and record its installed application version only after migration succeeds. Run the same upgrade workflow for each affected site.

Operation Command
Inspect pending, applied, and missing migrations wp your-plugin migrate
Preview pending SQL wp your-plugin migrate --run --dry-run
Apply pending migrations wp your-plugin migrate --run
Reconcile to a registered ID wp your-plugin migrate --run --to=<id>
Reverse the highest applied ID wp your-plugin migrate --rollback
Reverse several applied IDs wp your-plugin migrate --rollback --step=2
Reverse applied IDs above a target wp your-plugin migrate --rollback --to=<id>
Reverse all applied migrations wp your-plugin migrate --rollback --to=0 --yes
Reverse and rerun everything wp your-plugin migrate --refresh

Programmatic equivalents are Migrator::status(), preview($target), migrate($target), rollback($steps), rollbackTo($target), and refresh(). Migration operations return step objects containing the stable ID, direction, SQL, and whether a forward data callback was involved. To add a readable status description, implement DescribesMigration alongside Migration:

use StellarWP\Foundation\Migrations\Contracts\DescribesMigration;

// Add DescribesMigration to the migration's implements list.
public function describe(): string {
	return 'Create report storage';
}

migrate($target) and --run --to=<id> reverse applied IDs above the target in descending order, then apply pending IDs through it in ascending order. latest applies all pending migrations. rollbackTo($target) and --rollback --to=<id> only reverse applied IDs above the target; any pending IDs, including the target itself, remain pending. Rollback counts IDs, not deployment batches. Developers own dependencies and choosing a safe target, especially when a newly enabled package adds an older ID. Missing applied migration files must be restored before execution can continue.

MySQL DDL can commit before the history write. A retry compares the actual schema with the migration’s declared change: compatible existing additions and already-absent removals count as completed work. An interrupted index replacement resumes its missing work. Undeclared columns, indexes, and constraints are retained during alterations.

An incompatible existing declaration stops the run with IncompatibleSchema; Foundation does not silently reconcile unrelated drift. Inspect and correct the mismatch before retrying. LedgerFailure means a history write failed after migration work; fix the storage failure and retry with the same declarations. Any data callback must tolerate repetition.

If the ledger itself is wrong, pause all application upgrade triggers and migration workers for the affected site and take a backup. Restore missing migration files first, then compare the ledger’s exact IDs with the live schema and each migration’s schema and data effects.

Use your database administration tool against the configured physical ledger table, including its WordPress site prefix. For a confirmed bookkeeping error:

  • Insert the migration’s exact version only after verifying that its complete up() and any migrate() data callback already succeeded. The ledger supplies applied_at automatically.
  • Delete its version row only after verifying that its complete inverse has already been performed and that recorded dependent migrations remain valid.

These are deliberate manual SQL changes, not schema repairs. Do not change history to suppress a genuine IncompatibleSchema mismatch. Restore the intended schema first when history is accurate. After correcting history, inspect status and preview the next run before resuming upgrades. Keep an operational record of the repair.

One database advisory lock covers planning, schema execution, data callbacks, and history writes. The lock survives DDL commits and lasts until release or session termination. It has no lease TTL to configure. MigrationAlreadyRunning means another session is migrating this application’s site ledger: defer and retry after it finishes. This is distinct from a database failure.

MigrationInterrupted means ownership or the starting session could not be confirmed. The database layer reports AdvisoryLockInterrupted during a data callback; the migrator translates it to MigrationInterrupted when it escapes the run. Catching it inside a callback does not make the run successful. Foundation stops and releases only the original session’s lock where possible. Never change sites or sessions during a migration, even temporarily. Foundation migration exceptions live under StellarWP\Foundation\Migrations\Exceptions and extend MigrationException, which extends StellarWP\Foundation\Database\Exceptions\DatabaseException. Catch MigrationException for shared reporting, and use the specific exception when choosing whether to defer, retry, or stop. Native SQL failures still use Doctrine exceptions; application data callbacks can propagate their own exceptions.

All migration participants must reach the same primary database server. Advisory locks are local to that server; a proxy that moves statements between sessions or servers cannot provide this guarantee. Preview and status are observations and can become stale before a later run.

With foundation.prefix set to your-plugin, the ledger defaults to your_plugin_foundation_migrations before WordPress adds its site prefix. The advisory lock is scoped by database and ledger name, so applications using different ledgers migrate independently. Keep that name stable across releases.

To override the ledger name, add migrations.table only when configured in root config.php:

$config = [
	'foundation' => [
		'root'   => __DIR__,
		'prefix' => $_ENV['FOUNDATION_PREFIX'] ?? 'your-plugin',
	],
];

if ( isset( $_ENV['FOUNDATION_DATABASE_MIGRATIONS_TABLE'] ) ) {
	$config['migrations']['table'] = $_ENV['FOUNDATION_DATABASE_MIGRATIONS_TABLE'];
}

return $config;

Set the path once in root config.php; generation and runtime discovery use the same setting:

return [
	'foundation' => [
		'root' => __DIR__,
	],
	'migrations' => [
		'path' => 'migrations',
	],
];

Merge this with your existing configuration. Paths are relative to foundation.root; absolute paths such as __DIR__ . '/db/migrations' also work. An explicitly configured directory must exist when running migrations. Generation creates the directory when writing its first file. An absent default directory means the application has no discovered migrations yet.

Migration directories do not need Composer autoload mappings or a classmap rebuild. Include the PHP files in production archives. Files are application code: keep executable work inside the documented methods, and use a top-level return new class extends Migration declaration.

Include your migration directory in the plugin’s production archive. The Foundation generator reads extra.strauss.namespace_prefix and writes prefixed Foundation imports when configured. Handwritten files, older migrations, or a changed namespace-prefix configuration may still contain imports that need rewriting: include their directory in Strauss’s call-site scan alongside src/. Verify that the packaged migration imports match the packaged Foundation namespace. PHP namespace scoping leaves filename IDs and literal historical table names unchanged.

Use a slash in the migration description:

vendor/bin/foundation make:database:migration reports/add_published_at --table=your_plugin_reports

With the default location, the file goes into db/migrations/reports/. Discovery includes subfolders, but execution is still globally ordered across all folders.

Generated filenames use <UTC timestamp>_<lowercase_description>.php, such as 20260924000001_add_published_at.php. Generation chooses a timestamp later than existing generated migrations in the configured tree, so consecutive commands preserve order. Developers still own dependencies when merging independently developed migrations. The filename without .php is the persistent ID; description changes after application are identity changes too. Discovery loads files matching 14 digits, an underscore, and a lowercase description containing letters, numbers, or underscores. Keep helper files under other names.

Packages or applications with additional migration sources can contribute objects through a provider:

$this->container->mergeArrayVar( MigrationsProvider::MIGRATIONS, static fn ( C $c ): array => [
	new MigrationRegistration( '20260923000001_package_setup', $c->get( Package_Migration::class ) ),
] );

Import StellarWP\Foundation\Migrations\MigrationsProvider, StellarWP\Foundation\Migrations\ValueObjects\MigrationRegistration, and StellarWP\Foundation\Container\Contracts\Resolver as C in that provider. Package_Migration should extend StellarWP\Foundation\Migrations\Migration, just like generated anonymous migrations. Explicit contributions and discovered migrations share one ordered collection; contribute each migration once. Registrations keep the ID separate from the declaration object, preserving optional data and description capabilities.

Extend Migration and implement up(); override down() when a safe inverse exists. Direct implementation of Contracts\Migration remains supported for declarations that need a different base class, but requires both methods. Optional data and description behavior use separate interfaces. Foundation preserves these extension contracts within 2.x, including inherited method signatures and constructor expectations. Adding a base-class method can collide with consumer methods, so the base class is not an unrestricted extension surface for new Foundation features.

The registration supplies the ID. IDs are compared in ascending byte order. They must be unique, nonblank, unpadded, and no more than 191 bytes; 0 and latest are reserved targets. Choose IDs whose lexical order respects dependencies. Applications using only explicit contributions can omit discovery configuration.

Copy the package stubs into foundation/stubs/database/ to customize generated code. Start with the CLI stub guide. Table namespaces remain configurable through generator settings; migration placement is controlled by migrations.path.

Test create, alteration, rollback, and retry against real database tables. Include a failure after successful DDL but before history recording, then verify that retry preserves existing rows and records the migration once. Register a fresh container per test and use application-specific test table names.