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.
Create and change a table
Section titled “Create and change a table”-
Create the database provider
Install the generator as a development dependency, then create the provider that will collect the application’s tables and migrations:
Register the generated
Plugin\Database\Providerin the application’s ordered provider list. See Register the database providers. -
Generate the table and its initial migration
This creates the table class and
Create_Reports_Tablemigration, then adds both registrations to the conventional database provider. Review the migration before running it: itsdown()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. -
Define the complete initial schema
Edit
src/Database/Migrations/Create_Reports_Table.phpand describe every column and index the table initially requires:Once this migration has been applied anywhere, do not change its blueprint or permanent migration ID.
-
Initialize storage and run the migration
Use the application’s configured WP-CLI prefix in place of
your-plugin. Run both idempotent commands during deployment. -
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:
In the new migration, declare only its additions, complete column changes, and removals, then call
Schema::alter(). Deploy it with the same--initializeand--runcommands. 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.
Generate migration files
Section titled “Generate migration files”Understand generated files
Section titled “Understand generated files”The generators use the project’s Composer namespace and create this feature structure by default:
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:
Understand migration classes
Section titled “Understand migration classes”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.
Customize generated output
Section titled “Customize generated output”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 uses the project-configured migration namespace and path, falling back to Database\\Migrations. Configure persistent defaults for providers, tables, and migrations in generator locations. 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:
Define table schemas
Section titled “Define table schemas”Identify the table
Section titled “Identify the table”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.
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.
Define the initial schema
Section titled “Define the initial schema”The generated src/Database/Migrations/Create_Reports_Table.php owns the complete schema required to create the table at that point in migration history:
Choose column types
Section titled “Choose column types”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:
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:
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.
Add indexes
Section titled “Add indexes”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:
Use primary() only for a custom primary key. bigIncrements() already creates the table’s primary key:
Apply an initial table definition
Section titled “Apply an initial table definition”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.
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:
Pass a fully qualified class when the table is outside the default Database\\Tables namespace:
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.
Alter an existing table
Section titled “Alter an existing table”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:
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():
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:
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.
Write data migrations
Section titled “Write data migrations”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.
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.
Register migrations
Section titled “Register migrations”The generators update an existing database provider automatically. When registering classes by hand, bind table services and contribute them from src/Database/Provider.php:
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.
Run migrations
Section titled “Run migrations”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.
Initialize migration storage
Section titled “Initialize migration storage”Create or reconcile Foundation’s migration ledger and lock table before running migrations:
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().
Apply pending migrations
Section titled “Apply pending migrations”Running the command without an operation displays migration status:
The migration column lists every configured or recorded identifier with its current status, batch, and run time:
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.
Run during deployment
Section titled “Run during deployment”A typical deployment initializes the Foundation tables, reviews pending work, runs it, and then confirms the final status:
--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 or rebuild
Section titled “Roll back or rebuild”Roll back the latest applied batch:
Roll back every configured migration and run them again:
Drop only Foundation’s migration ledger when intentionally resetting migration history:
Run migrations from PHP
Section titled “Run migrations from PHP”WP-CLI is the preferred deployment interface. For controlled environments that cannot invoke WP-CLI, resolve the same Migrator service from the application container:
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.
Inspect migration status from PHP
Section titled “Inspect migration status from PHP”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:
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.
Testing
Section titled “Testing”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:
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.