Back to Blog
LaravelPHPJune 23, 20264 min read

Laravel Online Schema Change: Mastering Ghost Table Shadowing

Laravel online schema change strategies are essential for high-traffic apps. Learn to use ghost table shadowing to eliminate downtime during migrations.

LaravelMySQLDatabaseMigrationDevOpsPerformancePHPBackend

When your users table crosses the 50-million-row mark, a simple ALTER TABLE statement in a Laravel migration becomes a production-killing event. Last quarter, I watched a colleague trigger a metadata lock that brought our entire checkout flow to a standstill for about 12 minutes. That’s when we stopped relying on native migrations for heavy schema changes and started architecting deterministic online schema change workflows.

Standard Laravel database migrations are convenient, but they are inherently blocking. When you run php artisan migrate, MySQL acquires an exclusive metadata lock on the table. For a massive table, the time required to copy data and rebuild indexes creates a window of downtime that most businesses simply can't afford.

The Ghost Table Shadowing Pattern

Instead of altering the live table, the ghost table shadowing pattern creates a "shadow" version of the target table with the desired schema. We then sync data from the original to the shadow in real-time, effectively performing an online schema change without locking the production write path.

Here is the high-level workflow we use:

  1. Create the Shadow Table: Run a CREATE TABLE statement with the new schema.
  2. Setup Triggers: Deploy INSERT, UPDATE, and DELETE triggers on the original table to mirror every mutation to the shadow table.
  3. Data Copy: Perform a background copy of existing rows from the original to the shadow in small batches.
  4. Rename/Swap: Once the shadow table is caught up, perform an atomic rename to swap the tables.

We first tried using standard DB::statement() calls within migrations to handle this, but it quickly became unmanageable. If a trigger failed or the batch process lagged, we were left with a partial migration and no clear way to roll back without data loss. We eventually moved this logic into a dedicated service layer, similar to the techniques discussed in Laravel Migrations for Blue-Green Deployments: A Practical Guide.

Implementing the Sync Logic

To keep the shadow table in sync, we define a migration that creates the shadow table and registers the triggers. You’ll want to ensure your Laravel application is prepared for these schema shifts, especially if you handle complex data structures as described in Laravel Serialization: Architecting Deterministic Payloads for High-Performance Queues.

PHP
#6A9955">// Migration to create the shadow table
public function up()
{
    DB::statement("CREATE TABLE users_shadow LIKE users");
    DB::statement("ALTER TABLE users_shadow ADD COLUMN last_login_ip VARCHAR(45)");
    
    #6A9955">// Create triggers to mirror changes
    DB::statement("
        CREATE TRIGGER trg_users_insert AFTER INSERT ON users
        FOR EACH ROW INSERT INTO users_shadow(id, ...) VALUES(NEW.id, ...);
    ");
    #6A9955">// Repeat for UPDATE and DELETE...
}

This approach works, but it's not without risks. Triggers add overhead to every write operation. In our case, we saw write latency increase by roughly 15ms per transaction on our busiest nodes. If your database is already pegged at high CPU utilization, you might need to look into Database performance: How to implement write-combining for hot rows to offset the trigger overhead.

Managing the Swap

The final step—the swap—must be atomic. In MySQL, you can use the RENAME TABLE command, which is atomic and blocks only for the brief moment required to update the table name metadata.

SQL
RENAME TABLE users TO users_old, users_shadow TO users;

Once the swap is complete, you can drop the old table and remove the triggers. If something goes wrong during the transition, the users_old table remains intact, providing an instant recovery path.

Why Not Use Tools Like gh-ost or pt-online-schema-change?

You might ask why we aren't just using GitHub's gh-ost or Percona’s pt-online-schema-change. The truth is, we do, but we wrap them in a custom Laravel console command. Building a custom wrapper allows us to:

  • Integrate the migration status into our Slack alerting system.
  • Halt the migration automatically if replica lag exceeds a threshold.
  • Ensure our Eloquent models stay in sync with the new schema before the final swap.

Final Thoughts

Architecting for zero-downtime deployment is rarely about the code you write; it's about the state you manage during the transition. Ghost table shadowing is powerful, but it requires rigorous testing. We always run these migrations against a sanitized production clone first to measure the exact impact on replication lag.

Next time, I’d like to experiment with a more event-driven approach, perhaps using a sidecar process to read from the MySQL binary log instead of relying on triggers. Triggers are simple, but they are tightly coupled to the database engine. If we ever migrate to a distributed SQL engine, that logic will need a total rewrite. For now, though, this pattern keeps our high-traffic services running smoothly while we iterate on our schema.

Similar Posts