Laravel & PHP4 min read

Zero-Downtime Laravel Migrations: The Expand-and-Contract Playbook

A detailed deployment sequence for safe Laravel schema changes, online indexes, resumable backfills, mixed versions, queues, validation, and rollback.

JBy Jeffrey Klaassen van Oorschot

A migration command can finish in milliseconds and still break production. The real system contains old web processes, new web processes, long-running queue workers, scheduled commands, read replicas, and rollback images at the same time. Zero downtime is a compatibility property across releases, not a property of one ALTER TABLE statement.

Classify the change before writing PHP

I classify schema work by lock risk, rewrite risk, data volume, replication impact, and mixed-version compatibility. Adding a nullable column is different from changing a column type. Creating an index on a busy table is different from indexing an empty one. Database engine and version matter more than the Laravel method name.

Before production, I inspect the SQL Laravel will run and test it against a production-like schema and row count. I confirm the database’s online or concurrent index behaviour and whether the statement may run inside a transaction. Framework convenience does not override database rules.

Expand: add a compatible shape

First add the new nullable column, table, or index without removing the old shape. Deploy code that can read both. When moving data, new writes usually populate both representations, while reads prefer the new value and fall back to the old one. I keep dual-write logic in one domain service so it cannot drift between controllers, commands, and jobs.

I do not rename a column in one deploy when old code may still reference it. I add the new name, copy data, switch reads, stop old writes, and remove the old column later. It is more steps and much easier to roll back.

A compatible first migration
Schema::table('orders', function (Blueprint $table) {
    $table->string('delivery_zone_id')->nullable();
});

$zoneId = $order->delivery_zone_id
    ?? $legacyZoneResolver->forAddress($order->delivery_address);

Backfill as an operational workload

A backfill is not a migration callback on a large table. I implement a restartable command or queued workload that processes stable primary-key ranges, commits small batches, records progress, and can be throttled. It selects only rows still missing the new value so repeating a batch is safe.

I monitor application latency, database CPU and I/O, lock waits, replica lag, error rate, batch duration, and remaining rows. The backfill pauses when it harms production. Finishing tonight is not more important than keeping checkout healthy.

  • Use stable primary-key ordering, not offset pagination
  • Keep transactions short
  • Make each row idempotent
  • Record the last completed range and error count
  • Throttle from database health, not a fixed guess

Switch reads with evidence

Once backfill is complete, I compare old and new representations and report mismatches. Then a feature flag or small release switches reads to the new shape while dual writes continue. Metrics count fallbacks to the old field. I wait beyond the longest queue delay, cache lifetime, scheduled job interval, and rollback window.

Only when fallback use is zero and the new path has survived normal traffic do I stop writing the old representation. This is the point where rollback may require application logic rather than a simple image change, so the decision is explicit.

Contract in a separate release

Removal is its own reviewed migration. I search application code, jobs, reports, data exports, analytics, and external consumers for the old field. I remove constraints or columns only after every deployed process is compatible. Destructive DDL runs during a known window with lock monitoring and a tested recovery plan.

Before contraction, rollback means deploying the previous image. After contraction, the previous image may not work. I document that point and keep a forward-fix plan. Pretending every database migration is reversible is less safe than stating exactly what recovery requires.

Use this in practice

  • Inspect generated SQL and database lock behaviour
  • Add compatible schema before changing reads
  • Centralize temporary dual writes
  • Backfill in resumable primary-key batches
  • Measure mismatches and legacy fallbacks
  • Contract only after the rollback window closes

Keep reading

Laravel & PHP

Useful Full-Text Search With Laravel and MySQL

A practical Laravel search guide covering full-text indexes, permissions, exact identifiers, relevance, engine limitations, testing, and safe rollout.

6 min readRead article
Laravel & PHP

Reliable Webhook Processing in Laravel

A practical Laravel webhook design covering signatures, duplicate delivery, fast acknowledgement, queues, ordering, recovery, and useful observability.

6 min readRead article