Skip to content

Migrations

Foundation migrations apply ordered database changes and record each successful run in a WordPress-backed ledger. Prefer the bundled WP-CLI command during deployment so initialization, locking, execution, and status reporting follow one path.

  1. Create the database provider

    Install the generator as a development dependency, then create the provider that will collect the application’s tables and migrations:

    composer require --dev stellarwp/foundation-cli
    vendor/bin/foundation make:database-provider

    Register the generated Plugin\Database\Provider in the application’s ordered provider list. See Register the database providers.

  2. Generate the table and its initial migration

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

    This creates the table class and Create_Reports_Table migration, then adds both registrations to the conventional database provider. Review the migration before running it: its down() method drops the complete table and its data.

    Choose a stable table name unique to your plugin. WordPress adds its site prefix, producing a name such as wp_your_plugin_reports. Keep the application table name fixed after deployment.

  3. Define the complete initial schema

    Edit src/Database/Migrations/Create_Reports_Table.php and describe every column and index the table initially requires:

    public function up( Schema $schema ): void {
        $blueprint = Blueprint::for( $this->table );
    
        $blueprint->bigIncrements( 'id' );
        $blueprint->string( 'status', 20 )->default( 'draft' );
        $blueprint->dateTime( 'created_at' );
        $blueprint->index( 'status', 'status' );
    
        $schema->create( $blueprint );
    }

    Once this migration has been applied anywhere, do not change its blueprint or permanent migration ID.

  4. Initialize storage and run the migration

    wp your-plugin migrate --initialize
    wp your-plugin migrate --run

    Use the application’s configured WP-CLI prefix in place of your-plugin. Run both idempotent commands during deployment.

  5. Create a new migration for every later change

    Do not edit the table class or original migration. Generate an alteration migration against the existing table class:

    vendor/bin/foundation make:database-migration Add_Publishing_To_Reports \
        --table=Reports_Table

    In the new migration, declare only its additions, complete column changes, and removals, then call Schema::alter(). Deploy it with the same --initialize and --run commands. See Alter an existing table for the complete API and rollback behavior.

The table class remains the stable identity and query gateway throughout this lifecycle. The creation migration owns the original schema, and every later migration owns one subsequent change.

The generators use the project’s Composer namespace and create this feature structure by default:

src/Database/
  Provider.php
  Migrations/
    Create_Reports_Table.php
  Tables/
    Reports_Table.php

When the conventional provider was created by make:database-provider, the table and migration generators add their registrations automatically. Register that provider in the application’s ordered provider list as shown in Database configuration.

For a provider generated at another location, pass the same file to later commands:

vendor/bin/foundation make:database-provider Reporting_Provider \
	--namespace=Plugin\\Reporting \
	--path=src/Reporting
vendor/bin/foundation make:database-table Reports_Table --migration \
	--table-name=your_plugin_reports \
	--provider=src/Reporting/Reporting_Provider.php

Every migration has one permanent identifier and two operations:

Member Purpose
id() Returns the byte-exact identifier stored in the migration ledger. Never change it after deployment.
up() Applies the schema or data change.
down() Reverses the change, or throws IrreversibleMigration when no safe inverse exists.

Constructor injection is available for application tables and other services. Foundation resolves each registered migration through the container when a migration operation runs.

The conventional generator paths and class names require no extra options. When a project uses a different structure, the table generator’s --namespace and --path options customize the table class; its migration remains in the conventional Database\\Migrations namespace and path. Pass an explicit identifier such as --migration-id=2026_09_04_143200_create_reports_table only when the generated timestamp identifier must be replaced.

The table generator derives the unprefixed WordPress table name from the class name unless --table-name is supplied. For a standalone plugin, choose a name unique to that plugin, such as --table-name=your_plugin_reports. The value may contain only ASCII letters, numbers, and underscores. Foundation applies the active WordPress prefix at runtime, producing a physical name such as wp_your_plugin_reports. Supply the application table name without the WordPress site prefix. The migration generator’s --table option has a separate, class-oriented meaning: it selects the existing table class that an alteration migration changes.

An application that owns the entire installation can use the class-derived default, such as reports. A wrapper for an existing table should use that table’s established name without its WordPress site prefix.

Project-specific stubs can override the defaults at:

foundation/stubs/database/provider.stub
foundation/stubs/database/table.stub
foundation/stubs/database/create-table-migration.stub
foundation/stubs/database/alter-table-migration.stub
foundation/stubs/database/migration.stub

The generated src/Database/Tables/Reports_Table.php owns the table’s stable identity and table-scoped query gateway. Its inherited name() method asks the database service to apply the current WordPress table prefix and validate the resulting physical name when the table is used.

<?php declare(strict_types=1);

namespace Plugin\Database\Tables;

use StellarWP\Foundation\Database\Table\Table;

final readonly class Reports_Table extends Table {

	private const string UNPREFIXED_TABLE_NAME = 'your_plugin_reports';

	public function unprefixedName(): string {
		return self::UNPREFIXED_TABLE_NAME;
	}

}

The table class does not contain a mutable “current schema.” Each migration owns the exact blueprint it applies, so later table changes do not alter the meaning of migrations that have already shipped.

The generated src/Database/Migrations/Create_Reports_Table.php owns the complete schema required to create the table at that point in migration history:

public function up( Schema $schema ): void {
	$blueprint = Blueprint::for( $this->table );

	$blueprint->bigIncrements( 'id' );
	$blueprint->string( 'status', 20 )->default( 'draft' );
	$blueprint->longText( 'payload' )->comment( 'Serialized report payload' );
	$blueprint->dateTime( 'created_at' );
	$blueprint->dateTime( 'updated_at' )->nullable();
	$blueprint->index( 'status', 'status' );

	$schema->create( $blueprint );
}

Use the named helpers for common WordPress table columns:

Method Database definition Typical use
bigIncrements( 'id' ) Unsigned BIGINT, auto-incrementing primary key Numeric row identifiers
string( 'name', 191 ) VARCHAR with a configurable length Names, states, and short values
unsignedInteger( 'count' ) Unsigned INT Non-negative counters and identifiers
integer( 'position' ) Signed INT Counts and positions
tinyInteger( 'enabled', 1 ) TINYINT Flags and small numeric values
bigInteger( 'external_id' ) Signed BIGINT Large numeric values
dateTime( 'created_at' ) DATETIME, optionally with precision from 1 to 6 WordPress-compatible timestamps
text( 'excerpt' ) TEXT Medium text values
longText( 'payload' ) LONGTEXT Serialized payloads and large text values

Column modifiers can be combined on the declaration being configured. Inside src/Database/Migrations/Create_Reports_Table.php, for example:

$blueprint->string( 'status', 20 )->default( 'draft' );
$blueprint->unsignedInteger( 'attempts' )->default( 0 );
$blueprint->tinyInteger( 'enabled', 1 )->unsigned()->default( true );
$blueprint->dateTime( 'published_at' )->nullable()->default( null );
$blueprint->longText( 'payload' )->comment( 'Serialized report payload' );

Available modifiers are unsigned(), nullable(), notNull(), default(), autoIncrement(), and comment(). An explicit default( null ) is valid only on a nullable column.

Prefer bigIncrements() for the usual generated primary key. When applying autoIncrement() manually, use an integer column without a default, define only one auto-increment column in the table, and make it the first column in a primary, unique, or regular index. Foundation validates these requirements before executing schema SQL.

Use column() when the named helpers do not cover the required MySQL type. In src/Database/Migrations/Create_Reports_Table.php, import StellarWP\Foundation\Database\Table\Column with the other imports, then add the custom columns to its blueprint:

$blueprint->column( new Column( 'amount', 'decimal(10,2)' ) )->default( 0 );
$blueprint->column( new Column( 'checksum', 'varbinary', 32 ) )->nullable();

For decimal columns, specify both precision and scale when fractional values are needed, such as decimal(10,2). Omitting the scale uses zero fractional digits: decimal(10) and new Column( 'amount', 'decimal', 10 ) both declare decimal(10,0). Omitting precision as well uses decimal(10,0). The numeric and dec aliases follow the same rules.

In src/Database/Migrations/Create_Reports_Table.php, declare indexes after their columns. Index names must be unique within the table, and composite index columns are stored in the order provided:

$blueprint->bigIncrements( 'id' );
$blueprint->string( 'uuid', 26 );
$blueprint->string( 'status', 20 );
$blueprint->dateTime( 'created_at' );

$blueprint->unique( 'uuid_unique', 'uuid' );
$blueprint->index( 'status_created_at', 'status', 'created_at' );

Use primary() only for a custom primary key. bigIncrements() already creates the table’s primary key:

$blueprint->unsignedInteger( 'site_id' );
$blueprint->bigInteger( 'external_id' )->unsigned();
$blueprint->string( 'status', 20 );

$blueprint->primary( 'site_id', 'external_id' );

The generated src/Database/Migrations/Create_Reports_Table.php passes its complete blueprint to Schema. For a missing table, the schema service uses dbDelta() and verifies the requested columns and indexes before the migration is recorded as successful. If the table already exists, Foundation verifies the historical creation blueprint without modifying the table.

<?php declare(strict_types=1);

namespace Plugin\Database\Migrations;

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

final readonly class Create_Reports_Table implements Migration {

	public const string ID = '2026_08_21_120000_create_reports_table';

	public function __construct(
		private Reports_Table $table
	) {
	}

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

	public function up( Schema $schema ): void {
		$blueprint = Blueprint::for( $this->table );

		$blueprint->bigIncrements( 'id' );
		$blueprint->string( 'status', 20 )->default( 'draft' );
		$blueprint->longText( 'payload' )->comment( 'Serialized report payload' );
		$blueprint->dateTime( 'created_at' );
		$blueprint->dateTime( 'updated_at' )->nullable();
		$blueprint->index( 'status', 'status' );

		$schema->create( $blueprint );
	}

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

The --migration flag explicitly selects a create-table migration. Its generated down() method therefore drops the table and all of its data when the migration is rolled back.

You can generate the initial migration separately when the table class already exists:

vendor/bin/foundation make:database-migration Create_Reports_Table \
	--create=Reports_Table

Pass a fully qualified class when the table is outside the default Database\\Tables namespace:

vendor/bin/foundation make:database-migration Create_Reports_Table \
	--create=Plugin\\Reporting\\Tables\\Reports_Table

Foundation never infers table ownership from the migration name. Only --create selects the destructive create-table rollback, so a migration named Create_Reports_Table without that option remains a generic, irreversible migration.

Migration IDs are permanent, byte-exact identifiers that determine forward execution order. Foundation sorts every configured migration globally from the lowest ID to the highest ID, regardless of which provider contributed it. The generator prefixes IDs with a sortable timestamp so migrations normally follow creation time. IDs generated within the same second use their class-name suffix to determine their exact order. Do not change an ID after the migration has been deployed.

An explicit ID such as --id=2026_09_04_143200_create_reports_table remains supported when a project needs to preserve an established identifier. Custom IDs participate in the same bytewise lexical ordering, so use a consistently sortable convention. A migration introduced with an ID lower than an already-applied migration still runs as the next pending migration on an existing installation; Foundation never inserts work into previously completed history.

Create a new migration for every later schema change. The table class remains unchanged because the new migration owns the next step in its schema history:

vendor/bin/foundation make:database-migration Add_Publishing_To_Reports \
	--table=Reports_Table

The generated src/Database/Migrations/Add_Publishing_To_Reports.php receives Reports_Table and starts with an empty blueprint. Declare only the operations owned by this migration, then pass them to Schema::alter():

public function up( Schema $schema ): void {
	$blueprint = Blueprint::for( $this->table );

	$blueprint->dateTime( 'published_at' )->nullable();
	$blueprint->string( 'status', 40 )->default( 'draft' )->change();
	$blueprint->dropIndex( 'status' );
	$blueprint->index( 'status_published_at', 'status', 'published_at' );
	$blueprint->dropColumn( 'legacy_payload' );

	$schema->alter( $blueprint );
}

/**
 * @throws IrreversibleMigration Until a safe inverse is implemented.
 */
public function down( Schema $schema ): void {
	throw IrreversibleMigration::forMigration( self::ID );
}

Declarations without change() add a missing column. Mark a declaration with change() only when it replaces an existing column. Because MySQL requires MODIFY COLUMN to restate the complete column declaration, include every supported attribute that must remain, including length, nullability, default, unsigned state, auto-increment, and comment.

dropColumn() and dropIndex() express destructive removals. The generated down() remains irreversible until you provide a safe inverse; implement it only when rollback can restore the intended schema and data.

Foundation treats an existing addition and an absent removal as already completed. This makes the migration safe to retry when MySQL applied the DDL but Foundation could not write its ledger record. An existing column or index with a different requested definition fails verification instead of being silently accepted.

To replace an index, declare dropIndex() and the new index definition under the same name in one blueprint. Foundation replaces the index when its definition differs and skips the replacement when it already matches, so retrying the migration does not rebuild a completed index.

Use specialized or externally managed indexes

Section titled “Use specialized or externally managed indexes”

Foundation verifies indexes declared by the migration being applied. Other physical indexes are left alone because they may belong to an earlier migration, a plugin integration, or a database administrator.

The blueprint supports primary, unique, and regular indexes over complete columns. Use Schema::execute() for trusted schema SQL when an upgrade requires prefix lengths, descending columns, FULLTEXT, SPATIAL, primary-key changes, or another shape the blueprint does not represent. A later blueprint will not reject that external index unless the migration explicitly declares or removes the same name.

In src/Database/Migrations/Add_Report_Search.php, the generated table base resolves the active WordPress prefix through name(). Quote that physical name and every trusted identifier before executing specialized SQL:

public function up( Schema $schema ): void {
	if ( $schema->hasIndex( $this->table, 'report_search' ) ) {
		return;
	}

	$schema->execute( sprintf(
		'ALTER TABLE %s ADD FULLTEXT KEY %s (%s)',
		$schema->quoteIdentifier( $this->table->name() ),
		$schema->quoteIdentifier( 'report_search' ),
		$schema->quoteIdentifier( 'payload' )
	) );
}

Raw schema SQL bypasses blueprint retry handling. Check whether its requested state already exists before executing it so a migration can run again after the DDL succeeds but the ledger write fails.

This example assumes the injected table extends Foundation’s Table base. A custom implementation of the minimal Table contract should inject TableNameResolver when it needs the active physical name.

For equality-based data changes, use the table’s write methods so the migration needs only the table it changes. For example, src/Database/Migrations/Backfill_Report_Status.php can update existing rows without coordinating a separate database service:

Generate a generic migration without --create or --table, then add the table constructor dependency manually. Use --table only when the generated migration should start with an explicit Schema::alter() blueprint.

<?php declare(strict_types=1);

namespace Plugin\Database\Migrations;

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

final readonly class Backfill_Report_Status implements Migration {

	public const string ID = '2026_08_24_120000_backfill_report_status';

	public function __construct(
		private Reports_Table $table
	) {
	}

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

	public function up( Schema $schema ): void {
		$this->table->update(
			[ 'status' => 'active' ],
			[ 'status' => 'legacy' ]
		);
	}

	/**
	 * @throws IrreversibleMigration Until a safe inverse is implemented.
	 */
	public function down( Schema $schema ): void {
		throw IrreversibleMigration::forMigration( self::ID );
	}
}

Choose the raw SQL API based on the statement. Database::execute() accepts WordPress placeholders followed by their bindings, so use it when a data migration includes request, configuration, or stored values. Schema::execute() accepts only a complete SQL string and does not bind placeholders; reserve it for trusted schema SQL whose identifiers and literals are fully controlled by the application.

The generators update an existing database provider automatically. When registering classes by hand, bind table services and contribute them from src/Database/Provider.php:

<?php declare(strict_types=1);

namespace Plugin\Database;

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

final class Provider extends Service_Provider {
	private bool $registered = false;

	public function register(): void {
		if ( $this->registered ) {
			return;
		}

		$this->register_tables();
		$this->register_migrations();

		$this->registered = true;
	}

	private function register_tables(): void {
		$this->container->singleton( Reports_Table::class );
		// foundation:database-tables
	}

	private function register_migrations(): void {
		$this->container->mergeArrayVar(
			DatabaseProvider::MIGRATIONS,
			static fn ( C $c ): array => [
				$c->get( Create_Reports_Table::class ),
				$c->get( Add_Publishing_To_Reports::class ),
				$c->get( Backfill_Report_Status::class ),
			]
		);
	}
}

Foundation combines migrations contributed by every provider and executes pending migrations in ascending byte-exact ID order. Provider registration order does not control migration execution. Give schema prerequisites lower IDs than data migrations that depend on them.

The migration command accepts one operation at a time:

Goal Command
Show migration status wp your-plugin migrate
Create or reconcile migration storage wp your-plugin migrate --initialize
Run every pending migration wp your-plugin migrate --run
Roll back the latest batch wp your-plugin migrate --rollback
Roll back and rerun all configured migrations wp your-plugin migrate --refresh
Remove only the migration ledger wp your-plugin migrate --drop-store

The destructive --refresh and --drop-store operations prompt for confirmation. Add --yes only in an environment where the operation has already been approved.

Create or reconcile Foundation’s migration ledger and lock table before running migrations:

wp your-plugin migrate --initialize

Run this idempotent command during every deployment. Replace your-plugin with the configured command prefix; applications using the default prefix run wp nx migrate --initialize.

On WordPress multisite, run the command once for each site by passing WP-CLI’s --url global argument. Each site owns its migration ledger and lock table. See Use database services on multisite before migrating from code that calls switch_to_blog().

wp your-plugin migrate --run

Running the command without an operation displays migration status:

wp your-plugin migrate

The migration column lists every configured or recorded identifier with its current status, batch, and run time:

+-------------------------------------------------+---------+-------+---------------------+
| migration                                       | status  | batch | ran_at              |
+-------------------------------------------------+---------+-------+---------------------+
| 2026_09_04_143200_create_reports_table          | applied | 1     | 2026-09-04 20:32:10 |
| 2026_09_04_151500_add_status_to_reports         | pending |       |                     |
+-------------------------------------------------+---------+-------+---------------------+

The runner acquires the configured migration lock, executes pending migrations in ascending byte-exact ID order, and records each successful migration in one batch. Rollback follows the reverse of the ledger’s actual execution order, including when a newly introduced migration has an ID lower than migrations that were already applied.

A typical deployment initializes the Foundation tables, reviews pending work, runs it, and then confirms the final status:

wp your-plugin migrate --initialize
wp your-plugin migrate
wp your-plugin migrate --run
wp your-plugin migrate

--initialize is idempotent, so keep it in every deployment rather than branching between first installs and upgrades. Treat a failed command as a failed deployment step; do not continue serving code that expects a migration which did not complete.

Roll back the latest applied batch:

wp your-plugin migrate --rollback

Roll back every configured migration and run them again:

wp your-plugin migrate --refresh --yes

Drop only Foundation’s migration ledger when intentionally resetting migration history:

wp your-plugin migrate --drop-store --yes

WP-CLI is the preferred deployment interface. For controlled environments that cannot invoke WP-CLI, resolve the same Migrator service from the application container:

use StellarWP\Foundation\Database\Migration\Migrator;

$migrator = $container->get( Migrator::class );

$migrator->initialize();
$result = $migrator->run();

The result exposes the migration IDs that were run, rolled back, or skipped through its ran, rolledBack, and skipped properties. The programmatic API follows the same ledger and lock rules as the command. Do not run migrations during every normal WordPress request.

Migrator::status() returns one Status object for each configured or recorded migration. Each status is in exactly one state:

  • isPending() identifies a configured migration with no ledger record.
  • isApplied() identifies a configured migration with a ledger record.
  • isUnavailable() identifies a ledger record whose migration is not registered in the current deployment.

An unavailable migration has a ledger record and may have been applied by an earlier deployment. isApplied() checks the exact configured-and-recorded state; it does not answer whether any ledger record exists. Use these methods for application logic instead of comparing raw status values:

use StellarWP\Foundation\Database\Migration\Migrator;
$migrator = $container->get( Migrator::class );

foreach ( $migrator->status() as $migration_status ) {
	if ( $migration_status->isUnavailable() ) {
		// The ledger contains a migration that is not registered in this deployment.
	}
}

Every status includes the migration identifier. Applied and unavailable migrations also include the ledger batch and ranAt timestamp; pending migrations expose null for both values. The state() method returns the textual state name when it is needed for presentation.

Use wpunit tests for migration blueprints, schema operations, and migrations that execute against WordPress. Use integration when the test proves contributions from multiple providers, and use wpcli for the real migration command lifecycle.

Create and remove application tables within the test lifecycle so tests exercise the real wpdb and dbDelta() behavior rather than a PHP fake.

For example, a project base test case that exposes the application container can resolve the real schema and table services in tests/wpunit/Database/Reports_Table_Test.php:

<?php declare(strict_types=1);

namespace Plugin\Tests\WPUnit\Database;

use StellarWP\Foundation\Database\Contracts\Schema;
use Plugin\Database\Migrations\Create_Reports_Table;
use Plugin\Database\Tables\Reports_Table;
use Plugin\Tests\TestCase;

final class Reports_Table_Test extends TestCase {

	public function test_it_creates_the_reports_table(): void {
		$schema    = $this->container->get( Schema::class );
		$table     = $this->container->get( Reports_Table::class );
		$migration = $this->container->get( Create_Reports_Table::class );

		try {
			$migration->up( $schema );

			$this->assertTrue( $schema->hasTable( $table ) );
			$this->assertTrue( $schema->hasIndex( $table, 'status' ) );
		} finally {
			$schema->drop( $table );
		}
	}
}

Keep migration orchestration tests separate from schema-operation tests. An orchestration test should initialize an isolated ledger, run the configured migration through Migrator, and assert both the schema effect and recorded status. Use the wpcli suite when the behavior under test is the command output, confirmation, or exit status.