Skip to content

Migrations

Foundation migrations describe historical changes to application tables. Foundation orders registered migrations, acquires a database advisory lock, applies the remaining schema changes, and records each successful migration. Doctrine supplies schema inspection, comparison, and platform SQL.

After configuring DatabaseProvider, install the development CLI and generate an application provider:

composer require --dev stellarwp/foundation-cli
vendor/bin/foundation make:database-provider
vendor/bin/foundation make:database-table reports --table-name=your_plugin_reports --migration

The generator creates a table and its initial migration, and adds their registrations to the generated provider. Register that provider after DatabaseProvider in your application’s composition root.

The table owns only its stable name. The migration owns the initial schema:

In src/Database/Migrations/Create_Reports_Table.php:

<?php declare(strict_types=1);

namespace Plugin\Database\Migrations;

use Plugin\Database\Tables\Reports_Table;
use StellarWP\Foundation\Database\Migration\Contracts\Migration;
use StellarWP\Foundation\Database\Migration\Schema\Blueprint;

final readonly class Create_Reports_Table implements Migration {

	public const string ID = '2026_09_22_000001_create_reports_table';

	public function __construct(
		private Reports_Table $table,
	) {
	}

	public function id(): string {
		return self::ID;
	}

	public function up( Blueprint $schema ): void {
		$table = $schema->create( $this->table );
		$table->bigIncrements( 'id' );
		$table->string( 'title' );
		$table->string( 'status', 20 )->default( 'draft' );
		$table->decimal( 'amount', 12, 4 )->default( '0' );
		$table->dateTime( 'created_at', 6 )->useCurrent();
		$table->index( 'status_lookup', 'status' );
	}

	public function down( Blueprint $schema ): void {
		$schema->drop( $this->table );
	}
}

Review the generated schema before running it:

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

The first run creates the migration ledger automatically. The command prefix comes from your WP-CLI configuration. 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.

Providers contribute migration objects lazily. Registration order does not determine execution order:

In src/Database/Provider.php:

<?php declare(strict_types=1);

namespace Plugin\Database;

use Plugin\Database\Migrations\Create_Reports_Table;
use Plugin\Database\Tables\Reports_Table;
use StellarWP\Foundation\Container\Contracts\Provider as Service_Provider;
use StellarWP\Foundation\Container\Contracts\Resolver as C;
use StellarWP\Foundation\Database\DatabaseProvider;

final class Provider extends Service_Provider {

	public function register(): void {
		$this->container->singleton( Reports_Table::class );
		$this->container->mergeArrayVar( DatabaseProvider::MIGRATIONS, static fn ( C $c ): array => [
			$c->get( Create_Reports_Table::class ),
		] );
	}
}

All providers contribute to one globally sorted collection. IDs are compared in ascending byte order and stored exactly, independently of class names and Strauss scoping. IDs must be unique, nonblank, unpadded, and no more than 191 bytes. 0 and latest are reserved targets. Generated timestamps provide ordering; custom IDs, including numeric timestamps, must occupy the lexical position their dependencies require.

Generate an alteration for an existing table:

vendor/bin/foundation make:database-migration add-published-at --table=Reports_Table

Its up() declares only this migration’s changes:

public function up( Blueprint $schema ): void {
	$table = $schema->table( $this->table );
	$table->dateTime( 'published_at', 6 )->nullable();
	$table->string( 'title', 255 )->change();
}

change() replaces a column’s complete definition. Restate any default, nullability, unsigned flag, comment, or other attribute you want to retain. Use dropColumn('name'), dropIndex('name'), index('name', ...$columns), and unique('name', ...$columns) for explicit removals and index changes. An index replacement declares the old index’s removal and the new definition under the same name.

The generated down() throws IrreversibleMigration until you supply a safe inverse. A generic migration does the same. Only an explicit --create=Reports_Table generates an initial-table declaration whose inverse drops the entire table. --create and --table are mutually exclusive; a migration’s name never implies destructive 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.

Implement the optional MigratesData contract alongside Migration when a forward migration must transform rows:

use Doctrine\DBAL\Connection;
use StellarWP\Foundation\Database\Migration\Contracts\MigratesData;

// Add MigratesData to the migration's implements list.
public function migrate( Connection $db ): void {
	$db->executeStatement(
		'UPDATE ' . $this->table->quotedName() . ' SET status = ? WHERE status IS NULL',
		[ 'draft' ],
	);
}

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.

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\Database\Migration\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 classes 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 classes 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. Foundation stops and releases only the original session’s lock where possible. Never change sites or sessions during a migration, even temporarily. Migration exceptions live under StellarWP\Foundation\Database\Migration\Exceptions and extend StellarWP\Foundation\Database\Exceptions\DatabaseException. Catch a specific exception when choosing whether to defer, retry, or stop.

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.

The defaults are Database\Tables, Database\Migrations, and Database beneath your Composer application namespace. Use the CLI generator configuration to choose project namespaces, or command options --namespace and --path for individual output. Stub overrides live under foundation/stubs/database/. Keep migrations in production autoloaded code even when the generator itself is installed with --dev.

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.