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.
Unlike WordPress’s dbDelta(), which does not manage foreign-key constraints, Foundation supports creating, changing, and removing relationships between tables. See foreign keys for the complete workflow.
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 in src/App.php:
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.
Choose column types
Section titled “Choose column types”Use the same column methods when creating a table or adding columns to an existing one:
| Method | Storage and typical use |
|---|---|
bigIncrements() |
Unsigned auto-incrementing BIGINT primary key, named id by default |
string( 'name', 191 ) |
Variable-length text, with a default limit of 191 characters |
char( 'currency', 3 ) |
Fixed-length character storage; specify the length explicitly |
text( 'summary' ) |
Text up to 65,535 bytes |
mediumText( 'content' ) |
Text up to 16,777,215 bytes |
longText( 'document' ) |
LONGTEXT storage for larger documents |
smallInteger( 'attempts' ) |
SMALLINT; use unsigned() for nonnegative values |
integer( 'quantity' ), bigInteger( 'external_id' ) |
INT and BIGINT; unsigned convenience methods are also available |
boolean( 'enabled' ) |
Boolean storage |
decimal( 'amount', 12, 4 ) |
Exact numeric storage with explicit precision and scale |
json( 'metadata' ) |
JSON document storage with database validation |
date( 'starts_on' ) |
Calendar date, such as 2026-09-24 |
time( 'opens_at' ) |
Time with whole-second precision, such as 09:30:00 |
dateTime( 'created_at', 6 ) |
DATETIME with optional fractional-second precision from 0 to 6 |
binary( 'token', 16 ) |
Variable-length binary storage with an explicit byte limit |
Columns are non-nullable unless you call nullable(). Existing modifiers such as default(), comment(), and change() apply to the new types too. A char() length is a storage choice, not application validation that a supplied code has exactly that many characters. useCurrent() and useCurrentOnUpdate() are intended for dateTime() columns.
MySQL stores JSON natively; MariaDB uses text with JSON validation. Foundation preserves that distinction through migration retries and renames. For compatibility with MySQL 5.7, supply a JSON value on insertion or make the column nullable instead of declaring a non-null JSON default. When writing PHP arrays through a table or Doctrine connection, bind the column as Doctrine\DBAL\Types\Types::JSON so Doctrine encodes it. Query results are not automatically decoded; see queries and parameter types.
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.
Rename a column
Section titled “Rename a column”Generate an alteration for the table whose column is changing:
Replace the generated methods with the rename and its inverse:
Remove the generated IrreversibleMigration import and annotation when supplying this inverse. renameColumn() retains the column’s values and complete definition, including its default, nullability, and comment. Indexes and foreign keys continue to refer to the renamed column. Use a distinct destination name that does not already exist. Column renames run before other alterations on that table; use the new name in a change() declaration. When creating a new table, declare columns with their final names.
Keep renamed objects identifiable until the migration is recorded. Put reuse of an old name or removal of the renamed object in a later migration. Foundation rejects ambiguous sequences, such as swapping two column names, because a retry cannot distinguish their completed state.
Column renames work across the supported database versions. Foundation uses native RENAME COLUMN when available and otherwise uses CHANGE with the existing column definition. Your migration declaration stays the same.
Preview and apply the migration:
Update application queries to use headline when deploying this schema change. Leave the earlier migration that created title unchanged. Running --rollback reverses the rename through down(), preserving the values under title again.
Rename a table
Section titled “Rename a table”Generate a migration without --create or --table, then fill in its methods:
Remove the generated irreversible exception import and annotation. Both arguments are unprefixed names; Foundation applies the current WordPress site prefix. The destination must be unused. The rename preserves existing rows, indexes, and foreign-key relationships.
Preview and apply with wp your-plugin migrate --run --dry-run and wp your-plugin migrate --run. Update the application table class’s unprefixedName() to return 'your_plugin_articles', and use that name in new migrations. Keep all earlier migration filenames and historical table names unchanged. Coordinate these application changes with migration execution; application queries must use the name currently present in the database.
Existing foreign keys retain their physical constraint names after a table rename. Continue using their original logical names with dropForeignKey() on the renamed table. If a later migration creates a different table under the old table name, give its foreign keys different logical names from those still owned by the renamed table, so their physical names do not collide.
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:
Relate tables with foreign keys
Section titled “Relate tables with foreign keys”Foreign keys let the database enforce relationships between your application’s tables.
For example, an order item must belong to an existing order. Create the parent table before its dependent table:
The first migration’s generated bigIncrements( 'id' ) supplies the order’s primary key. In the second migration, complete up() with the item columns, supporting index, and relationship:
Keep the generated down() that drops your_plugin_order_items. The later migration ID ensures rollback drops items before orders. When adding a relationship to existing tables, use --table=your_plugin_order_items and $schema->table() instead.
order is the relationship’s stable logical name within the items table. Foundation scopes the physical constraint name to that table, so another table can also use order. Pass the same logical name to dropForeignKey() later. references() takes the historical, unprefixed application table name; Foundation resolves both tables for the current WordPress site.
Foreign-key drift errors include the logical name, such as order, alongside the database constraint name, such as fk_…. Use the logical name to locate your migration declaration and the database name to inspect the constraint. Constraints without a known declaration are identified by their database name alone.
If you change WordPress’s table prefix, rename the application’s tables and migration ledger together before running migrations again. Keep historical migration declarations unchanged. Existing constraint names can retain the old prefix’s identity; Foundation recognizes a uniquely matching historical relationship by its columns, referenced table, and update/delete actions. Later migrations can still change or remove it. If multiple constraints make that identity ambiguous, migration planning stops with IncompatibleSchema so you can inspect the named constraints before retrying.
Declare columns and indexes explicitly. The local and referenced column types must match, including integer size and unsignedness. Reference a primary or unique key. Foundation-created tables use InnoDB, which enforces these constraints.
Preview and apply as usual:
The database now rejects an item whose order_id has no matching order. Deleting an order also deletes its items because this example chooses cascadeOnDelete().
Choose deletion and update behavior
Section titled “Choose deletion and update behavior”Without an action method, both deletion and referenced-key updates are restrictive: the database rejects the parent change while child rows reference it.
| Delete action | Referenced-key update action | Effect on child rows |
|---|---|---|
restrictOnDelete() |
restrictOnUpdate() |
Reject the parent change while references exist |
cascadeOnDelete() |
cascadeOnUpdate() |
Delete dependent rows, or propagate the new key |
nullOnDelete() |
nullOnUpdate() |
Set the referencing columns to NULL |
For either null action, declare every local column as nullable():
Changing an existing relationship requires a new migration. Drop and redeclare the same logical name with its complete replacement definition:
Its down() can drop and redeclare the relationship with the original action. To remove a relationship entirely, use only dropForeignKey( 'order' ). This retains its columns and indexes; remove those separately when that migration owns their removal. Remove dependent relationships or child tables before dropping a referenced parent table.
Reference a composite key
Section titled “Reference a composite key”Supply columns in corresponding order on both sides. For example, given an orders table with a unique key over account_id and order_number, declare matching item columns and their index:
The earlier parent migration must declare those column types and $table->unique( 'account_order', 'account_id', 'order_number' ). Each local value pairs with the referenced column in the same position.
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.
Retries also recognize completed table and column renames, then continue any remaining work without renaming them again. A conflicting destination or a missing source and destination stops execution with IncompatibleSchema; inspect the live names before retrying.
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.