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.
Create and change a table
Section titled “Create and change a table”Set up migrations
Section titled “Set up migrations”Install the migrations runtime and development CLI. The runtime includes Foundation Database and the WP-CLI integration:
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:
After creating the application container with that configuration, register providers in dependency order:
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.
Generate a new table
Section titled “Generate a new table”Create the table class and its first migration together:
The command writes two files:
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.
Define the initial schema
Section titled “Define the initial schema”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:
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.
Preview and apply
Section titled “Preview and apply”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.
Add a column later
Section titled “Add a column later”Generate a new migration for the existing table:
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:
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:
Existing reports have NULL in published_at until your application writes a value.
Roll back a change
Section titled “Roll back a change”To reverse the highest applied migration, inspect its down() and run:
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.
Write schema changes
Section titled “Write schema changes”up() and down() declare schema changes. Foundation may replay them during planning, including previews. Keep queries and application work in the separate data callback.
Change a column
Section titled “Change a column”Declare the complete replacement definition and call 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.
Remove a column
Section titled “Remove a column”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.
Add or replace an index
Section titled “Add or replace an index”Use dropIndex() for removal. To replace an index under the same name, declare both the removal and its replacement:
Choose a scaffold
Section titled “Choose a scaffold”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.
Column declarations
Section titled “Column declarations”| 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.
Transform existing data
Section titled “Transform existing data”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:
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.
Deploy and recover
Section titled “Deploy and recover”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:
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.
Interrupted schema changes
Section titled “Interrupted schema changes”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.
Repair inconsistent history
Section titled “Repair inconsistent history”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
versiononly after verifying that its completeup()and anymigrate()data callback already succeeded. The ledger suppliesapplied_atautomatically. - Delete its
versionrow 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.
Concurrent upgrades
Section titled “Concurrent upgrades”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.
Configure discovery
Section titled “Configure discovery”Name the migration ledger
Section titled “Name the migration ledger”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:
Use another directory
Section titled “Use another directory”Set the path once in root config.php; generation and runtime discovery use the same setting:
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.
Package migrations with Strauss
Section titled “Package migrations with Strauss”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.
Group migrations by feature
Section titled “Group migrations by feature”Use a slash in the migration description:
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.
Contribute migrations explicitly
Section titled “Contribute migrations explicitly”Packages or applications with additional migration sources can contribute objects through a provider:
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.
Customize stubs and test migrations
Section titled “Customize stubs and test migrations”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.