DEV Community

Cover image for Expand-and-Contract: Safe Database Migrations for Zero-Downtime Laravel Deploys
Deploynix
Deploynix

Posted on Originally published at deploynix.io

Expand-and-Contract: Safe Database Migrations for Zero-Downtime Laravel Deploys

Earlier this year we helped a team debug a deploy that should have been boring. They renamed users.name to users.full_name, updated every reference in the codebase, wrote a one-line migration with renameColumn(), and shipped. Their deploy pipeline was genuinely zero-downtime: new release directory, atomic symlink flip, graceful FPM reload. And yet their app returned HTTP 500 for roughly 40 seconds, right in the middle of business hours. Every request that touched a user record died with Column not found: 1054 Unknown column 'name'.

Nothing in their pipeline was broken. The migration ran before the symlink flipped, which meant the old release, the one still serving live traffic, spent 40 seconds querying a column that no longer existed. The deploy tooling did exactly what it promised. The migration was the problem, because it assumed code and schema change at the same instant. They never do.

That gap between "schema changed" and "code changed" is not a bug you can fix with better tooling. It's a structural property of any deploy that keeps serving traffic while it works. The only reliable answer is a discipline called expand-and-contract (sometimes "parallel change"): make every schema change in phases, so that every migration is compatible with both the release before it and the release after it. This post walks through the pattern end to end, with real Laravel migration code, two worked examples, the places where you can honestly skip the ceremony, and the guardrails that catch dangerous migrations before they reach production.

If you're new to zero-downtime deployment itself, our primer on what zero-downtime deployment is and why your Laravel app needs it covers the foundations. Here we're going one layer deeper, into the part of the deploy that atomic symlinks can't protect: your database.

Why Does "Just Run the Migration" Break Under Zero-Downtime Deploys?

A zero-downtime deploy never stops serving traffic, which means there is always a window where two versions of your application coexist against one database. Either the new schema serves old code (migrations run before the release flips) or the old schema serves new code (migrations run after). There is no ordering that avoids the overlap entirely.

Here's the timeline for a typical release-directory deploy, the same sequence we described in the anatomy of a zero-downtime deploy:

TimeDeploy stepCode serving trafficSchema in the databaseT+0sNew release directory created, code clonedOld releaseOldT+4scomposer install, config cachedOld releaseOldT+9s*Migrations run* (deploy hook)Old releaseNewT+14sSymlink flips to new release, FPM reloadsNew releaseNewT+14s onwardIn-flight requests on old workers drain*Old release (briefly)*New

Look at T+9s through the drain window. The old release is live against the new schema for several seconds, longer if migrations are slow, and longer still on multi-server fleets where the flip isn't perfectly simultaneous. If your migration removed or renamed anything the old code reads, every one of those requests fails.

Flip the ordering and you trade one problem for another. Run migrations after the symlink flip, and new code briefly runs against the old schema, crashing on columns that don't exist yet. Queue workers make the window wider: a worker mid-job during the deploy finishes that job on old code, against whatever schema exists at that moment.

Rollback makes the asymmetry worse. Code rollback is instant, the symlink just points back at the previous release. Schema rollback is not. migrate:rollback in a panic, against live traffic, on a table that just got rebuilt, is how small incidents become large ones. So the practical rule falls out naturally: every migration must be safe to run while the previous release is still serving traffic, and every release must run correctly against the next release's schema. One release of compatibility, in both directions. Expand-and-contract is just the systematic way to satisfy that rule.

Which Migration Operations Are Actually Dangerous?

Most migrations are fine. Adding a nullable column, adding a table, adding most indexes: old code ignores what it doesn't know about, and nothing breaks. The danger is concentrated in a small set of operations that change or remove something the running release depends on.

OperationWhy it breaks live trafficExpand-and-contract alternative*Rename a column or tableOld code still selects and writes the old name. Every query touching it fails the moment the migration commits.Add the new column, dual-write from code, backfill, flip reads, drop the old column in a later release.Drop a column or tableOld code (and cached SELECT * expectations, serialized queue jobs, in-flight requests) still references it.Stop referencing it in release N. Drop it in release N+1, once nothing running can touch it.Change a column typeThe ALTER often rebuilds the table (locks, replication lag), and old code may write values invalid under the new type, or read values it can't handle.Add a new column with the new type, dual-write, backfill, flip reads, drop the old column later.Add NOT NULL to an existing columnOld code inserts rows without that column and hits a constraint violation instantly. On Postgres, SET NOT NULL also takes an ACCESS EXCLUSIVE lock while it scans the table.Add the column nullable, dual-write, backfill, then enforce NOT NULL only after every writer populates it (using CHECK ... NOT VALID then VALIDATE on Postgres).Add a unique index to a big table*The build can lock writes, and old code may insert duplicates mid-build, failing the migration.Deduplicate first, then build the index without blocking writes: CREATE INDEX CONCURRENTLY on Postgres, ALGORITHM=INPLACE on MySQL.

A note on engine behavior, because "it's fast on my machine" hides real differences. MySQL 8 performs many ALTER TABLE operations as INSTANT or INPLACE: adding a nullable column at the end of a table is metadata-only and effectively free at any table size. But type changes and some NOT NULL additions still require a full table rebuild (ALGORITHM=COPY), which on a 50-million-row table means minutes of load and replication lag even when it technically doesn't block reads. Postgres gives you transactional DDL, which is a genuine safety net for failed migrations, but ALTER TABLE still takes an ACCESS EXCLUSIVE lock. A lock that waits behind one long-running query will queue every other query behind it, and a "fast" migration turns into a site-wide stall. Fast DDL is not the same as safe DDL.

What Is the Expand-and-Contract Pattern?

The pattern splits one breaking change into three non-breaking phases, shipped across separate releases. At no point does any deployed release depend on schema that the adjacent release can't tolerate.

Phase 1: Expand

Add the new thing without touching the old thing. New nullable column, new table, new index. Deploy code that writes to both old and new locations but still reads from the old one. This release is compatible with the old schema (the new column is nullable, so old rows are fine) and with the old code (which simply ignores the new column). Nothing can break, in either direction, including a rollback.

Phase 2: Migrate

Backfill existing rows from old to new, in chunks, from a queued command, never inside the migration itself. A migration that loops over 10 million rows holds your deploy hostage and, on some setups, times out halfway through with no clean resume point. Once the backfill completes and you've verified parity, flip reads to the new column. Gate the flip behind a config check or a feature flag if you want a kill switch; we covered that mechanism in rolling out changes safely with Laravel Pennant. Keep dual-writing. That's what makes this phase reversible.

Phase 3: Contract

Only when no deployed release reads or writes the old column, remove it. This is its own release, deliberately boring. The drop is safe precisely because releases N and N-1 both ignore the column. If you're tempted to fold the contract migration into the same release that flips reads, resist it: that's the exact shortcut that turns a rollback into an incident.

Three phases, three releases, minimum. It feels slow the first time. It stops feeling slow the first time a Phase 2 release gets rolled back at 5 p.m. and nothing happens, because the schema was compatible in both directions by construction.

Worked Example: Splitting users.name Into first_name and last_name

Let's do the exact change that burned the team in the intro, done properly. Goal: replace the single name column with first_name and last_name.

Release 1: Expand and dual-write

The migration adds the new columns, nullable, so existing rows and old code need no changes:


php
Enter fullscreen mode Exit fullscreen mode

Top comments (0)