It is 14:05, the deploy pipeline is green, and the error tracker lights up with SQLSTATE[42S22]: Column not found: 1054 Unknown column 'name'. The migration that renamed users.name to users.full_name ran fine. The problem is that for about forty seconds, the old release was still serving traffic on half the servers, and the old code still selects name. Queue workers that had not restarted yet were even worse: they kept failing jobs until someone ran php artisan queue:restart by hand.
Nothing in that story is a bug in the migration itself. The bug is the assumption that schema and code change at the same instant. They never do. During every deploy there is a window where old code runs against the new schema, or new code runs against the old schema, and on a rolling or blue/green deploy that window can last minutes. Zero-downtime migrations are about making every schema change compatible with both versions of the code that might be running at the same time.
Why "Just Run the Migration" Breaks in Production
A typical deploy does three things: ship new code, run php artisan migrate --force, switch traffic. Whatever order you pick, there is overlap:
- Migrate first, then switch code: old code runs against the new schema. Renames and drops break it immediately.
- Switch code first, then migrate: new code runs against the old schema. Any new column it reads does not exist yet.
- Rolling deploys (Kubernetes, multiple Forge servers, Vapor): both versions serve traffic at the same time for the whole rollout.
- Queue workers and scheduled commands keep the old code in memory until they restart, so they lag behind web servers.
- Rollback: if the new release is bad, you redeploy the old code. If the migration already dropped something, the old code is now broken too.
There is also the database side. On a large table, some ALTER TABLE operations take a metadata lock or rewrite the table. MySQL 8 can do many changes with ALGORITHM=INSTANT or INPLACE, and PostgreSQL adds nullable columns without a rewrite, but a long-running UPDATE users SET full_name = name on 20 million rows will hold row locks, bloat the undo log or WAL, and stall replication. So "zero downtime" has two halves: compatible code, and small, lock-friendly database operations.
The Expand/Contract Pattern
Expand/contract (also called parallel change) splits one breaking change into several non-breaking ones. You expand the schema so that both old and new shapes exist, migrate the data and the code in small steps, then contract by removing the old shape once nothing uses it. Each step is its own deploy, and each deploy is safe to roll back to the previous one.
For the name to full_name rename, the phases look like this:
- Expand: add
full_nameas a nullable column. Old code ignores it. Nothing breaks. - Dual write: deploy code that writes to both
nameandfull_name, but still readsname. Every new or updated row now has both values. - Backfill: copy
nameintofull_namefor old rows, in small batches, from a job or command, not inside the migration transaction. - Switch reads: deploy code that reads
full_name(with a fallback tonameif you are cautious), still writing both. - Stop writing the old column: deploy code that only touches
full_name. Keepnamearound for one more release so a rollback still works. - Contract: drop
namein a later release, and only then makefull_nameNOT NULL if you need that.
Yes, that is up to five deploys for one rename. That is the price of never having an outage window, and it pays for itself the first time you need to roll back.
Implementation in Laravel
Phase 1: expand
Keep the migration tiny and additive. No data copying here.
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('full_name')->nullable()->after('name');
});
}
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropColumn('full_name');
});
}
};
The column is nullable on purpose. If you add NOT NULL with no default, old code that inserts users without full_name will fail on the very next signup.
Phase 2: dual write in the model
The cleanest place to dual write is a model event or a mutator, so every code path (controllers, imports, Filament forms, seeders) gets it for free.
namespace App\Models;
use Illuminate\Foundation\Auth\User as Authenticatable;
class User extends Authenticatable
{
protected $fillable = ['name', 'full_name', 'email', 'password'];
protected static function booted(): void
{
static::saving(function (User $user): void {
if ($user->isDirty('name') && ! $user->isDirty('full_name')) {
$user->full_name = $user->name;
}
if ($user->isDirty('full_name') && ! $user->isDirty('name')) {
$user->name = $user->full_name;
}
});
}
}
Model events do not fire for query builder updates like User::where(...)->update([...]) or raw SQL. Search the codebase for mass updates on that table and fix them by hand, or the backfill will keep finding stale rows.
Phase 3: backfill in batches
Run the backfill as an Artisan command or a queued job, not inside the migration. Migrations run inside the deploy and, on PostgreSQL, inside a transaction; a twenty-minute migration blocks the deploy and holds locks for twenty minutes.
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
class BackfillUserFullName extends Command
{
protected $signature = 'backfill:user-full-name {--batch=1000} {--sleep=100}';
protected $description = 'Copy users.name into users.full_name in small batches';
public function handle(): int
{
$batch = (int) $this->option('batch');
$sleepMs = (int) $this->option('sleep');
$total = 0;
DB::table('users')
->whereNull('full_name')
->select(['id'])
->chunkById($batch, function ($rows) use (&$total, $sleepMs): void {
$ids = $rows->pluck('id')->all();
$updated = DB::table('users')
->whereIn('id', $ids)
->whereNull('full_name')
->update(['full_name' => DB::raw('name')]);
$total += $updated;
$this->info("Updated {$total} rows, last id {$ids[array_key_last($ids)]}");
usleep($sleepMs * 1000);
});
$this->info("Done. {$total} rows backfilled.");
return self::SUCCESS;
}
}
A few details matter here:
chunkByIdpages withWHERE id > ? ORDER BY id LIMIT n(keyset pagination). Plainchunk()usesOFFSET, which gets slower on every page and, worse, skips rows when theWHEREcondition changes under it. Because we are updating exactly the column we filter on,chunk()would silently skip half the table.- The update repeats
whereNull('full_name'), so the command is idempotent. If a user edits their profile mid-backfill, the dual write already setfull_nameand we do not overwrite it. You can kill and restart the command safely. - Each batch is its own short statement, so locks are held for milliseconds, and replicas can keep up. The sleep gives replication and other traffic room to breathe. Watch replica lag while it runs and tune
--batchand--sleepaccordingly. DB::raw('name')makes the database copy the value directly, with no round trip through PHP.
If the table is huge and the IDs are dense, an even simpler loop is to walk fixed ID ranges (WHERE id BETWEEN 1 AND 1000, then 1001 to 2000 and so on). That avoids the extra SELECT per batch and makes progress trivially resumable: just log the last range you finished. The challenge below asks you to build exactly that range splitter.
Phase 4 and 5: switch reads, then stop writing the old column
Switch reads with an accessor fallback for one release. It costs nothing and protects you from rows the backfill missed.
namespace App\Models;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Foundation\Auth\User as Authenticatable;
class User extends Authenticatable
{
protected function displayName(): Attribute
{
return Attribute::get(fn (): string => $this->full_name ?? $this->name ?? '');
}
}
Before phase 5, verify that the backfill really finished: SELECT COUNT(*) FROM users WHERE full_name IS NULL AND name IS NOT NULL must be zero. Then remove every write to name. Grep for it in Blade views, API resources, exports, raw SQL and $fillable.
Phase 6: contract
Only after the release that stopped writing name has been live long enough that you will not roll back past it, drop the column. Tightening full_name to NOT NULL can go in the same migration.
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('full_name')->nullable(false)->change();
$table->dropColumn('name');
});
}
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->string('name')->nullable()->after('id');
$table->string('full_name')->nullable()->change();
});
}
};
Notice that down() cannot restore the data in name. That is normal for a contract step and the reason it ships last, after you are confident. Since Laravel 11, ->change() requires you to repeat every attribute you want to keep (length, default, comment), because modifiers that you omit are dropped.
Deploy Order Cheat Sheet
The rule of thumb: additive schema changes go before the code that needs them; destructive schema changes go after the code that stopped needing them.
| Change | Migration runs | Code change |
|---|---|---|
| Add nullable column or new table | Before the code deploy | Next release starts using it |
| Add index | Before, ideally online | None |
| Rename column | Never directly; expand/contract | Spread across releases |
| Drop column | After code stopped reading and writing it | Previous release |
| Add NOT NULL | After backfill is complete | Code always writes the value |
| Change type | New column plus backfill | Like a rename |
Also restart queue workers in every deploy (php artisan queue:restart, or php artisan horizon:terminate with Horizon); they are the slowest part of your fleet to pick up new code.
Testing the Backfill
You cannot unit test downtime, but you can test that each phase does what it claims: that the backfill is idempotent, does not overwrite rows the dual write already handled, and processes everything across multiple batches. A Pest feature test with RefreshDatabase covers it.
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
uses(RefreshDatabase::class);
it('backfills full_name in batches without overwriting newer values', function () {
User::factory()->count(25)->create(['full_name' => null]);
DB::table('users')->update(['full_name' => null]);
$edited = User::factory()->create(['name' => 'Old Name']);
DB::table('users')->where('id', $edited->id)->update(['full_name' => 'New Name']);
$this->artisan('backfill:user-full-name', ['--batch' => 10, '--sleep' => 0])
->assertSuccessful();
expect(DB::table('users')->whereNull('full_name')->count())->toBe(0)
->and(DB::table('users')->where('id', $edited->id)->value('full_name'))->toBe('New Name');
$this->artisan('backfill:user-full-name', ['--batch' => 10, '--sleep' => 0])
->assertSuccessful();
expect(DB::table('users')->whereColumn('full_name', '!=', 'name')->count())->toBe(1);
});
The batch size of 10 against 26 rows forces at least three chunks, which is where pagination bugs hide. In CI, also run php artisan migrate:rollback --step=1 after each expand migration to prove the down() method works; a rollback that has never been executed is a rollback that does not work.
Common Pitfalls
- Backfilling inside the migration. It runs during the deploy, blocks the pipeline and holds locks. Keep migrations to schema changes; move data to commands or jobs.
- Using
chunk()while modifying the filter column. Offset pagination skips rows. UsechunkById()orlazyById(). - Adding
NOT NULLin the expand step. Old code that does not know the column will fail on insert. Add constraints only after the backfill and after every writer sets the value. - Forgetting non-Eloquent writers. Query builder updates, raw SQL, other services sharing the database, and database triggers all bypass model events.
- Dropping too early. If you drop in the same release that stopped using the column, a rollback brings back code that needs it. Wait one release.
- Long-running
ALTER TABLEon hot tables. Check whether your engine can do the change online. For MySQL, tools likegh-ostorpt-online-schema-changeexist for changes that are not instant. - Not restarting workers. Queue workers keep old code alive long after the web tier switched.
When to Use It and When Not To
Use expand/contract when:
- You deploy to more than one server, use rolling or blue/green deploys, or run long-lived queue workers.
- The table is large enough that a full rewrite or single big
UPDATEwould lock it noticeably. - You need the option to roll back code without restoring a database backup.
- Several services or apps read the same table and cannot all deploy at once.
Skip it, or shortcut it, when:
- It is a brand-new table nobody reads yet: just change it.
- The app is small, single-server, and a two-minute
php artisan downwindow is acceptable to your users. - The change is purely additive (new nullable column, new table, new index created online). That is already the expand step; there is nothing to contract.
The mental shift is the important part: stop thinking of a migration as one event, and start thinking of it as a sequence of states where every state is compatible with the code before it and the code after it.
Try the challenge
Practice what you just learned. Write your solution, reveal hints if you get stuck.
Instructions
Write the helper that splits a backfill into fixed id ranges so it can run in small batches and resume after a crash. 1. idRangeBatches(int $minId, int $maxId, int $batchSize): array (Python: id_range_batches(min_id, max_id, batch_size) -> list[tuple[int, int]]) returns consecutive inclusive [start, end] pairs covering minId..maxId. Each batch holds at most batchSize ids and the last batch is clipped to maxId. Example: (1, 10, 4) gives [[1,4],[5,8],[9,10]]. If minId > maxId return an empty list. If batchSize < 1 throw InvalidArgumentException (Python: raise ValueError). 2. remainingBatches(int $minId, int $maxId, int $batchSize, ?int $lastDoneId): array (Python: remaining_batches(..., last_done_id: int | None)) returns the same batches but only those whose start id is greater than lastDoneId. A null/None lastDoneId means nothing was processed yet, so return every batch. In PHP return a list (re-indexed from 0); in Python return tuples.
Starter code
/**
* Split the inclusive id range [$minId, $maxId] into consecutive batches.
*
* @return list<array{0: int, 1: int}>
*/
function idRangeBatches(int $minId, int $maxId, int $batchSize): array
{
// TODO: throw InvalidArgumentException when $batchSize < 1
// TODO: return [[start, end], ...] covering $minId..$maxId inclusive,
// each batch at most $batchSize ids, last batch clipped to $maxId
// TODO: return [] when $minId > $maxId
return [];
}
/**
* Resume a backfill: return only the batches that start after $lastDoneId.
*
* @return list<array{0: int, 1: int}>
*/
function remainingBatches(int $minId, int $maxId, int $batchSize, ?int $lastDoneId): array
{
// TODO: null $lastDoneId means nothing was done yet: return every batch
// TODO: otherwise keep only batches whose start id is greater than $lastDoneId
return [];
}
def id_range_batches(min_id: int, max_id: int, batch_size: int) -> list[tuple[int, int]]:
"""Split the inclusive id range [min_id, max_id] into consecutive batches."""
# TODO: raise ValueError when batch_size < 1
# TODO: return [(start, end), ...] covering min_id..max_id inclusive,
# each batch at most batch_size ids, last batch clipped to max_id
# TODO: return [] when min_id > max_id
raise NotImplementedError
def remaining_batches(
min_id: int, max_id: int, batch_size: int, last_done_id: int | None
) -> list[tuple[int, int]]:
"""Resume a backfill: return only the batches that start after last_done_id."""
# TODO: None means nothing was done yet: return every batch
# TODO: otherwise keep only batches whose start id is greater than last_done_id
raise NotImplementedError
Your solution
Hints
All hints revealedStep through the range with start += batchSize and compute end as min(start + batchSize - 1, maxId).
A for loop whose condition is start <= maxId naturally returns an empty list when minId > maxId.
In PHP, array_filter keeps the original keys; wrap it in array_values so the result compares equal to a list.
/**
* Split the inclusive id range [$minId, $maxId] into consecutive batches.
*
* @return list<array{0: int, 1: int}>
*/
function idRangeBatches(int $minId, int $maxId, int $batchSize): array
{
if ($batchSize < 1) {
throw new InvalidArgumentException('Batch size must be at least 1.');
}
$batches = [];
for ($start = $minId; $start <= $maxId; $start += $batchSize) {
$batches[] = [$start, min($start + $batchSize - 1, $maxId)];
}
return $batches;
}
/**
* Resume a backfill: return only the batches that start after $lastDoneId.
*
* @return list<array{0: int, 1: int}>
*/
function remainingBatches(int $minId, int $maxId, int $batchSize, ?int $lastDoneId): array
{
$batches = idRangeBatches($minId, $maxId, $batchSize);
if ($lastDoneId === null) {
return $batches;
}
return array_values(array_filter($batches, fn (array $b): bool => $b[0] > $lastDoneId));
}
def id_range_batches(min_id: int, max_id: int, batch_size: int) -> list[tuple[int, int]]:
"""Split the inclusive id range [min_id, max_id] into consecutive batches."""
if batch_size < 1:
raise ValueError("batch_size must be at least 1")
return [
(start, min(start + batch_size - 1, max_id))
for start in range(min_id, max_id + 1, batch_size)
]
def remaining_batches(
min_id: int, max_id: int, batch_size: int, last_done_id: int | None
) -> list[tuple[int, int]]:
"""Resume a backfill: return only the batches that start after last_done_id."""
batches = id_range_batches(min_id, max_id, batch_size)
if last_done_id is None:
return batches
return [b for b in batches if b[0] > last_done_id]

