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:
| Time | Deploy step | Code serving traffic | Schema in the database |
|---|---|---|---|
| T+0s | New release directory created, code cloned | Old release | Old |
| T+4s | composer install, config cached | Old release | Old |
| T+9s | Migrations run (deploy hook) | Old release | New |
| T+14s | Symlink flips to new release, FPM reloads | New release | New |
| T+14s onward | In-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.
| Operation | Why it breaks live traffic | Expand-and-contract alternative |
|---|---|---|
| Rename a column or table | Old 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 table | Old 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 type | The 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 column | Old 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
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::table('users', function (Blueprint $table) {
$table->string('first_name')->nullable()->after('name');
$table->string('last_name')->nullable()->after('first_name');
});
}
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropColumn(['first_name', 'last_name']);
});
}
};
On MySQL 8 this is an INSTANT operation regardless of table size. Safe to run while the old release serves traffic, which is exactly when it will run.
The same release ships the dual-write. An attribute mutator is the cleanest place, because every code path that sets name (registration, profile updates, admin edits, factories) funnels through it automatically:
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Foundation\Auth\User as Authenticatable;
class User extends Authenticatable
{
protected $fillable = ['name', 'first_name', 'last_name', 'email', 'password'];
/**
* Dual-write: keep first_name/last_name in sync whenever name is set.
* Reads still come from the legacy `name` column during the expand phase.
*/
protected function name(): Attribute
{
return Attribute::make(
set: function (?string $value) {
[$first, $last] = array_pad(explode(' ', trim((string) $value), 2), 2, null);
return [
'name' => $value,
'first_name' => $first,
'last_name' => $last,
];
},
);
}
}
From this release forward, every new or updated user row carries all three columns. Rows that nobody touches stay stale, which is what the backfill is for.
Release 1.5: Backfill in chunks
The backfill is an Artisan command, not a migration. chunkById() paginates on the primary key (WHERE id > ? under the hood), so it won't skip rows even though we're updating the very column we filter on, and it never loads the whole table into memory:
<?php
namespace App\Console\Commands;
use App\Models\User;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
class BackfillUserNames extends Command
{
protected $signature = 'users:backfill-names {--chunk=500}';
protected $description = 'Populate first_name/last_name from the legacy name column';
public function handle(): int
{
$updated = 0;
User::query()
->whereNull('first_name')
->whereNotNull('name')
->chunkById((int) $this->option('chunk'), function ($users) use (&$updated) {
foreach ($users as $user) {
[$first, $last] = array_pad(explode(' ', trim($user->name), 2), 2, null);
DB::table('users')
->where('id', $user->id)
->whereNull('first_name')
->update(['first_name' => $first, 'last_name' => $last]);
$updated++;
}
usleep(50_000);
});
$this->info("Backfilled {$updated} users.");
return self::SUCCESS;
}
}
Details worth copying: the inner whereNull('first_name') makes each row update idempotent, so the command can crash and rerun without clobbering rows the dual-write already handled. The usleep() keeps replication lag polite on big tables. And because it's a command, you can run it as a queued job, watch it, throttle it, or kill it, none of which a migration allows. We've backfilled tables this way over several hours while the app served normal traffic the entire time.
Verify parity before moving on. A query as simple as User::whereNull('first_name')->whereNotNull('name')->count() returning zero is your gate to Phase 2's read flip.
Release 2: Flip reads
Now change every read site from $user->name to the new columns, or add an accessor that composes them:
protected function fullName(): Attribute
{
return Attribute::make(
get: fn () => trim("{$this->first_name} {$this->last_name}"),
);
}
Keep the dual-write mutator. This release reads new, writes both. If anything looks wrong, rolling back to Release 1 is completely safe: it reads the old column, which is still being written. That reversibility is the whole point.
Release 3: Contract
After Release 2 has been stable for a comfortable interval (we like a few days, minimum one full deploy cycle), remove the mutator's legacy write, delete remaining references, and drop the column:
<?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::table('users', function (Blueprint $table) {
$table->dropColumn('name');
});
}
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->string('name')->nullable();
});
}
};
Notice the down() recreates the column nullable. It can't resurrect the data, and it doesn't pretend to. If you ever need to roll back past a contract release, you're restoring from backup, not from down(). That's one more reason contract releases ship alone: keep the blast radius of the one irreversible step as small as possible.
Worked Example: Changing a Column Type and Enforcing NOT NULL
Second scenario, two dangers in one: orders.total is a FLOAT (someone's 2019 decision), and you want it to be an integer total_cents column that is NOT NULL. A naive ->change() plus ->nullable(false) in a single migration commits three sins at once: a table rebuild under load on MySQL, an ACCESS EXCLUSIVE lock plus full-table scan on Postgres, and instant constraint violations from the old release, which inserts orders without the new column.
Expand: new column, nullable, dual-write
Schema::table('orders', function (Blueprint $table) {
$table->unsignedBigInteger('total_cents')->nullable()->after('total');
});
Dual-write in the model, converting on the way in:
protected function total(): Attribute
{
return Attribute::make(
set: fn (float|int|string|null $value) => [
'total' => $value,
'total_cents' => $value === null ? null : (int) round(((float) $value) * 100),
],
);
}
Backfill with the same chunked-command shape as before, then verify with a parity query (whereNull('total_cents')->whereNotNull('total')->count() must hit zero, and spot-check the rounding on a sample).
Enforce NOT NULL without the lock
Only after every writer populates total_cents and the backfill is complete may you enforce the constraint. On MySQL 8, MODIFY ... NOT NULL on an already-clean column is quick but still an INPLACE rebuild of metadata; schedule it thoughtfully on very large tables. On Postgres, the naive ALTER TABLE ... SET NOT NULL takes ACCESS EXCLUSIVE while it scans every row. The pattern that avoids the scan-under-lock is a two-step check constraint:
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
public function up(): void
{
// Instant: no scan, existing rows unchecked for now.
DB::statement(
'ALTER TABLE orders ADD CONSTRAINT orders_total_cents_not_null
CHECK (total_cents IS NOT NULL) NOT VALID'
);
// Scans with a light SHARE UPDATE EXCLUSIVE lock; writes keep flowing.
DB::statement('ALTER TABLE orders VALIDATE CONSTRAINT orders_total_cents_not_null');
// Postgres 12+ sees the validated CHECK and skips the scan here.
DB::statement('ALTER TABLE orders ALTER COLUMN total_cents SET NOT NULL');
DB::statement('ALTER TABLE orders DROP CONSTRAINT orders_total_cents_not_null');
}
};
NOT VALID means "enforce for new writes, don't scan existing rows yet." VALIDATE CONSTRAINT does the scan under a lock that doesn't block reads or writes. And on Postgres 12 and later, the final SET NOT NULL notices the validated constraint and becomes a metadata-only change. Same end state as the naive version, none of the downtime.
While we're here: unique indexes
If the new column also needs a unique index, the same "don't block writes" thinking applies. On Postgres, CREATE UNIQUE INDEX CONCURRENTLY builds without locking writes, but it cannot run inside a transaction, and Laravel wraps Postgres migrations in one by default. Opt out per migration:
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
public $withinTransaction = false;
public function up(): void
{
DB::statement(
'CREATE UNIQUE INDEX CONCURRENTLY orders_reference_unique ON orders (reference)'
);
}
public function down(): void
{
DB::statement('DROP INDEX CONCURRENTLY IF EXISTS orders_reference_unique');
}
};
On MySQL 8, ALTER TABLE ... ADD UNIQUE INDEX ..., ALGORITHM=INPLACE, LOCK=NONE gives you the equivalent. Either way, deduplicate the data first; a unique index build that hits a duplicate fails partway, and on Postgres a failed concurrent build leaves an invalid index behind that you must drop manually. If a migration does fail mid-deploy, our guide on recovering from a failed database migration in production walks the cleanup step by step.
When Is It Honestly Fine to Skip the Ceremony?
We'd be lying if we claimed every team runs three-phase changes for every column. Plenty of teams cheat, and plenty get away with it. The useful question isn't "is cheating wrong," it's "what are you actually betting on when you cheat." Three postures, honestly compared:
Full expand-and-contract
Strengths: Zero user-visible errors by construction. Every phase is independently rollback-safe. Works at any table size, any traffic level, any fleet size. Turns scary changes into boring ones.
Best for: Hot tables (users, orders, anything in the request path), large tables, multi-server fleets, teams deploying many times a day, and any change where a 40-second error burst is unacceptable.
Considerations: Three releases instead of one. Dual-write code lives in your model for days. Requires the discipline to actually ship the contract phase instead of leaving zombie columns forever (put it in the ticket).
Direct migration, eyes open
Strengths: One release, one migration, done. For genuinely instant DDL on tiny or cold tables, the two-versions window might see zero affected requests.
Best for: Tables with a few thousand rows, admin-only features, columns referenced in one code path, internal tools where a handful of failed requests costs nothing.
Considerations: "Tiny and cold" is a judgment call that ages badly; the settings table that was cold last year is in the request path now. You're accepting a small error window on purpose. Write that acceptance down in the PR so it's a decision, not an accident.
Maintenance window
Strengths: Removes the two-versions problem entirely: no traffic, no compatibility constraint. Sometimes the honest choice for a gnarly multi-table restructure that would take five expand-contract cycles.
Best for: B2B apps with a real quiet period, once-a-year structural rewrites, changes entangled with data transformations too complex to dual-write.
Considerations: You're spending your downtime budget and your team's evening. Windows overrun; have a tested restore point before you start. If you need one every quarter, that's a signal the schema work is being batched instead of decomposed.
Our rule of thumb: expand-and-contract by default, direct migration only when you can name the table's row count and traffic pattern from memory, maintenance window as a deliberate exception you schedule, not a habit you drift into.
What Guardrails Catch Dangerous Migrations Before They Ship?
Discipline that depends on everyone remembering the rules isn't discipline, it's luck. Three cheap guardrails make the pattern stick.
A CI check that flags dangerous operations. You don't need a full linter to start; a grep over new migration files catches most sins:
<?php
// tests/Architecture/MigrationSafetyTest.php (Pest)
$dangerous = [
'renameColumn', 'dropColumn', 'dropTable', "->change()",
'DROP COLUMN', 'RENAME COLUMN', 'CHANGE COLUMN', 'MODIFY ',
];
it('flags dangerous operations in new migrations', function () use ($dangerous) {
$newMigrations = array_filter(
glob(database_path('migrations/*.php')),
fn ($path) => str_contains(shell_exec("git diff --name-only origin/main...HEAD") ?? '', basename($path))
);
foreach ($newMigrations as $path) {
$contents = file_get_contents($path);
foreach ($dangerous as $needle) {
expect(str_contains($contents, $needle))
->toBeFalse("Dangerous operation '{$needle}' in " . basename($path)
. '. Add // @safe-migration: <reason> after review, or use expand-and-contract.');
}
}
});
Let an explicit @safe-migration comment suppress the failure. The goal isn't to forbid drops, contract phases need them, it's to force a human to write down why this one is safe.
A migration review checklist. Four questions on every PR that touches database/migrations: Does this run correctly while the previous release serves traffic? Does the previous release run correctly against this schema (rollback safety)? Does any statement lock or rebuild a table over ~1M rows? Is any data movement in a chunked command rather than the migration? Thirty seconds to answer, and it catches the renames every time.
A pre-deploy backup, automatically. Guardrails reduce risk; they don't eliminate it. A backup taken immediately before migrations run turns "we dropped the wrong column" from a career event into a bad afternoon. We wrote up the full setup in automated database backups: set it, forget it, sleep well.
Rehearse on staging. For anything multi-phase, run the actual sequence, expand, backfill, flip, contract, against a staging database with production-shaped data volume. The backfill that takes 4 seconds on staging's 10k rows and 3 hours on production's 40M rows is a discovery you want to make on Tuesday, not during the deploy.
How This Plays Out on Deploynix
Everything above is platform-agnostic, but it's worth being concrete about how the pattern maps onto a real pipeline, because the pipeline is where the two-versions window physically lives. A Deploynix zero-downtime release works like this: new release directory, composer install, your deploy hooks run (migrations belong here), then the atomic symlink flip and FPM reload. Which means migrations always run while the previous release is still serving traffic. That's not a flaw to engineer around; it's the honest shape of every release-directory deploy, and it's exactly the window expand-and-contract is designed for. Additive expand migrations are safe in that window by definition.
Deploy hooks also give each phase a natural home. The expand migration runs in the pre-flip migration hook like any other. The backfill command doesn't belong in a hook at all: dispatch it as a queued job or run it as a one-off command after the release settles. Queue workers restart on every deploy, so a backfill written as a chunked, idempotent, resumable command (like the one above) shrugs off the restart and picks up where it left off. Long-running daemon-style backfills work too, for teams that prefer them.
The rollback interplay is where the discipline pays for itself most visibly. Rolling back a deployment on Deploynix switches the symlink back to the previous release in seconds. The schema, deliberately, stays where it is. Nobody should be running destructive migrate:rollback against a live database as a reflex, so the platform doesn't. That design choice only works if your migrations honor the one-release-compatibility rule: the previous release must run correctly against the current schema. Expand migrations pass that test automatically. A rename or drop fails it, and no rollback button can save you from a column that no longer exists. Add the automated pre-deploy backups to S3-compatible storage and a staging pipeline to rehearse the full expand-backfill-flip-contract sequence, and the pattern goes from "thing we know we should do" to "thing the pipeline makes easy to do."
FAQ
Do I really need three separate deploys for one renamed column?
Three phases, yes; three deploys minimum, though expand and backfill often ship together (the backfill runs after the deploy, not during it). For a small, cold table you may reasonably collapse the whole thing into one direct migration, but make that an explicit, written decision. For anything in the request path, the phases are the cost of never serving errors, and each individual deploy stays trivially reviewable.
Can I put the backfill in the migration if the table is small?
If it's genuinely small (thousands of rows, single-digit seconds), it's tolerable. The trap is that migrations don't get retired: the same migration file runs on a fresh developer machine, in CI, and on the production table two years and 20 million rows later. A chunked command is barely more code and never becomes the thing holding your deploy hostage, so we default to it above a few thousand rows.
What about migrate:rollback? Isn't that what down() is for?
down() is great for local development and useless as a production incident tool. Rolling back schema under live traffic recreates the exact two-versions problem in reverse, and destructive down() methods (recreating dropped columns without their data) actively lie about what they restore. In production, roll back code via the symlink, keep the schema, and rely on the fact that your migrations were forward-and-backward compatible by one release. Data-loss scenarios are what backups are for.
How long should I wait between the migrate phase and the contract phase?
Long enough that a rollback to the pre-flip release is no longer plausible: at minimum one full deploy cycle, in practice a few days for most teams. Concretely, contract only when no release that reads or writes the old column could ever serve traffic again. If you use release retention for rollbacks, wait until the last old-column release has aged out of your rollback window.
Does expand-and-contract apply to Postgres if DDL is transactional there?
Yes, fully. Transactional DDL means a failed migration rolls back cleanly, which is a genuine safety improvement, but it does nothing about the compatibility problem: a committed rename still breaks the old release the instant it commits, transaction or not. And Postgres has its own lock hazards (ACCESS EXCLUSIVE on ALTER TABLE, non-transactional CREATE INDEX CONCURRENTLY) that the phased approach is precisely built to sidestep.
Where to Go From Here
The habit worth building this week is small: before your next deploy, open the pending migrations and ask the two questions. Will this run correctly while the current release is still serving traffic? Will the current release run correctly against this schema if we roll back? If both answers are yes, ship it. If either answer is no, split the change: expand now, migrate next, contract later. It's the same work, sequenced so that no single deploy can take your app down.
And before you run your next risky migration anywhere near production, make sure a fresh backup lands automatically first. Set up automated pre-deploy database backups, then rename that column with the pattern instead of the prayer.