Laravel-Style Database Migrations in WordPress with Acorn
Database schema changes have always been WordPress’s weakest developer story. When a plugin needs a custom table, the usual pattern is: check if the table exists on activation, run a CREATE TABLE statement, bump a version option, and hope the rollout goes smoothly. Schema changes for already-installed sites mean comparing version numbers, running ALTER TABLE statements conditionally, and logging warnings when things don’t apply cleanly. There’s no versioned history, no rollback, and no way to see what schema a given site is actually running without inspecting the database.
Every other modern PHP framework solved this decades ago with database migrations: versioned, incremental schema changes stored in your codebase, applied in order, with full rollback support. Rails has them, Django has them, Laravel has them. WordPress core doesn’t — but if you’re using Acorn, you can.
This guide covers Laravel-style database migrations in WordPress with Acorn — why you’d want them, how to set them up, how to write them, how to run them during deploys, and how to handle the edge cases that come up when running migrations alongside WordPress’s own schema.
What Are Database Migrations?
A migration is a small, versioned PHP file that describes one change to your database schema:
- Create a table.
- Add a column to an existing table.
- Add an index.
- Change a column type.
- Drop an obsolete table.
Each migration file has two methods: up(), which applies the change, and down(), which reverses it. Migrations are stored in a database/migrations/ directory, named with a timestamp prefix so they run in order.
Laravel (and Acorn) tracks which migrations have run in a migrations table. Running wp acorn migrate applies any pending migrations; running wp acorn migrate:rollback reverses the most recent batch. The history is reproducible, versioned in Git, and consistent across every environment.
Why You Want Migrations in WordPress
WordPress’s schema — wp_posts, wp_postmeta, wp_users, wp_options — is good at what it does. But real applications often need custom tables for:
- High-volume structured data — analytics events, audit logs, activity streams — where
wp_postmetawould collapse under the weight. - Structured relational data — order line items, appointment bookings, customer records — that doesn’t fit the post/meta model cleanly.
- Reporting aggregates — pre-computed daily summaries, materialized views — where you want a purpose-built table.
- Third-party integration caches — rate-limited API responses, synced external records.
Without migrations, managing these tables across development, staging, and production is error-prone. With migrations, it’s a routine part of every deploy.
Setting Up Migrations in an Acorn Project
If you’re using Radicle, migrations are pre-wired out of the box — database/migrations/ exists, the migrations tracking table will be created on first run, and wp acorn migrate works immediately.
For Bedrock + Sage setups, you need to install Acorn’s database package:
composer require roots/acorn-database
Register it as a provider in app/Providers/ThemeServiceProvider.php, create a database/migrations/ directory at the theme root (or project root for Radicle), and configure config/database.php to point at the WordPress database.
Writing Your First Migration
Generate a migration with the Acorn console:
wp acorn make:migration create_audit_log_table
This creates database/migrations/2026_04_18_143022_create_audit_log_table.php:
use Illuminate\Database\Migrations\Migration;use Illuminate\Database\Schema\Blueprint;use Illuminate\Support\Facades\Schema;return new class extends Migration { public function up(): void { Schema::create('audit_log', function (Blueprint $table) { $table->id(); $table->unsignedBigInteger('user_id')->nullable()->index(); $table->string('action', 64)->index(); $table->text('description')->nullable(); $table->string('ip_address', 45)->nullable(); $table->timestamp('created_at')->useCurrent(); }); } public function down(): void { Schema::dropIfExists('audit_log'); }};
Run it:
wp acorn migrate
Acorn creates the audit_log table and records the migration as applied. If you want to undo it, wp acorn migrate:rollback runs the down() method.
Modifying an Existing Table
Later, you need to add a column. Generate a new migration:
wp acorn make:migration add_session_id_to_audit_log
The up/down methods:
public function up(): void { Schema::table('audit_log', function (Blueprint $table) { $table->string('session_id', 64)->nullable()->after('user_id'); $table->index('session_id'); });}public function down(): void { Schema::table('audit_log', function (Blueprint $table) { $table->dropIndex(['session_id']); $table->dropColumn('session_id'); });}
wp acorn migrate applies it. The migrations table tracks both of your migrations as applied; running migrate again is a no-op.
Handling the wp_ Prefix
WordPress uses a database table prefix (default wp_, often customized to something like emn_). By default, Laravel’s schema builder doesn’t know about this prefix.
Acorn’s database configuration handles this automatically — config/database.php reads the prefix from WordPress and applies it to every schema operation. So Schema::create('audit_log', ...) creates a table named wp_audit_log (or with your actual prefix).
If you need to query a table by its prefixed name explicitly — usually when working with WordPress core tables — use $wpdb->prefix or DB::table('wp_posts') with the prefix hardcoded. Don’t mix schema builders that handle prefixes automatically with raw SQL that doesn’t.
Running Migrations During Deploys
Add wp acorn migrate --force to your Trellis deploy hook. The --force flag is required in non-interactive environments (like CI), since the command otherwise prompts for confirmation in production.
In trellis/deploy-hooks/sites/example.com/build-after.yml:
- name: Run database migrations command: wp acorn migrate --force args: chdir: "{{ deploy_helper.new_release_path }}"
The migration runs after Composer install, before the symlink flip — so the new code and schema are aligned before traffic hits the new release.
Backward-Compatible Migrations for Zero-Downtime Deploys
Trellis’s zero-downtime deploys create a brief window where both the old and new code may be running (in-flight requests finish on the old release while new requests hit the new release). If the migration is backward-incompatible, this creates problems.
Rule: migrations should always be backward-compatible with the previous code version. If you need to remove a column:
- Deploy 1: Stop reading/writing the column in code. Column still exists in the database.
- Deploy 2: Run a migration that drops the column.
Similarly for renaming, restructuring, or any other change. Two small deploys beat one big breaking one.
Seeding
Related to migrations, Laravel seeders populate tables with default or test data. Acorn supports seeders too:
wp acorn make:seeder DefaultCategoriesSeederwp acorn db:seed
Seeders are useful for:
- Populating reference tables (countries, currencies, default categories).
- Creating test data in development environments.
- Initial setup when onboarding a new install.
Only run seeders in environments where it’s appropriate. A typical pattern: seeders run automatically in development, and manually (with a flag) in production.
Migrations and the WordPress Activation Hook
Traditional WordPress plugins use register_activation_hook() to create tables when the plugin activates. With Acorn migrations, you don’t need this — the migration runs on every deploy and creates missing tables.
For themes, there’s no activation hook. Run wp acorn migrate on initial install and on every deploy. Acorn’s migration tracking table ensures each migration only runs once, so re-running is safe.
Common Pitfalls
Migration Order Matters
Migrations run in filename order (which is why they’re timestamp-prefixed). Don’t edit historic migrations — instead, write a new migration for any change. Editing old migrations breaks environments that have already applied them.
Irreversible Migrations
Some migrations can’t be cleanly reversed — dropping a column loses the data, type-changing a column with data loss. Write a best-effort down() or throw an exception explaining that rollback isn’t supported for this migration.
Developers Running Migrations at Different Times
If two developers write migrations simultaneously, the one whose PR merges second will have a migration that runs after the first. That’s fine if they’re independent. If they conflict (both add a column to the same table), the second may fail. Coordinate schema changes in a team chat.
Schema Drift from Manual Changes
If someone makes a schema change directly in the database (e.g., via phpMyAdmin on production), your migrations won’t know about it. Discipline: all schema changes go through migrations.
Frequently Asked Questions
Do Acorn migrations modify WordPress core tables?
They can, but you shouldn’t. WordPress core tables (wp_posts, wp_users, etc.) are managed by WordPress itself. Don’t add columns to core tables via migrations — you’ll lose the changes when WordPress updates. Create your own tables for custom data.
Can I use migrations alongside WordPress dbDelta()?
Technically yes, but it’s confusing. Pick one. If you’re using Acorn, use migrations. If you’re writing a plugin that needs to support non-Acorn installs, use dbDelta().
Do I need a separate database for migrations?
No. Migrations use the same WordPress database. Acorn adds its own tracking table (default name: migrations, with the WordPress prefix).
What happens if a migration fails mid-way?
The migration’s changes aren’t wrapped in a transaction by default (many DDL operations can’t be rolled back in MySQL anyway). If a migration fails partway, you may have partial changes applied. Fix the migration, run migrate again, and manually reconcile anything that’s in a weird state.
Should migrations be committed to Git?
Yes. Always. Migrations are part of your application’s source code and must travel with it.
Do I run migrations on WordPress multisite?
Careful here. Multisite has shared tables (wp_users, wp_blogs) and per-site tables (wp_2_posts). Decide whether your custom table should be global (one table across the network) or per-site (one table per subsite). Global is simpler and usually correct. Per-site requires iterating over subsites in the migration.
Schema as Code, Finally
Once you’ve used migrations, going back to “plugin version option + conditional ALTER TABLE” feels primitive. Every schema change is an explicit, versioned, reviewable file. Deployments handle migrations automatically. Environments stay in sync. New team members can see the complete history of how the schema evolved by reading the migrations folder.
Acorn brings this to WordPress without asking you to leave WordPress. For any project with custom data beyond posts and options — ecommerce, SaaS, editorial pipelines, integrations — migrations are one of the most valuable Acorn features and worth adopting on day one.
At Emnes, we use Acorn migrations on every custom-data project. The one time we didn’t, we regretted it about six months in when a deploy went sideways and we couldn’t easily reconcile production and staging schemas. Don’t learn that lesson the hard way.
Related reading: Acorn explained, Trellis zero-downtime deployments, Sage 11 deep dive.