Most Laravel teams have a deploy pipeline that is genuinely zero-downtime for code — atomic symlink swaps, rolling containers, health checks — and then run php artisan migrate --force in the middle of it and take the site down anyway. A single ALTER TABLE on a large table, or a renameColumn that old application code still depends on, is enough to turn a two-second release into a ten-minute outage.
This tutorial covers the pattern that fixes it: expand and contract. It is the technique we reach for on almost every Laravel rescue and upgrade engagement, and it works on Laravel 13 the same way it worked on Laravel 8.
The real problem: two versions of your code, one schema
During any rolling deploy there is a window — seconds on Forge, minutes on Kubernetes — where old containers and new containers are both serving traffic against the same database. Queue workers make the window longer: a worker booted before the deploy keeps running old code until it is restarted, and a job serialised yesterday may be unserialised tomorrow.
So the rule is not "migrations must be fast". The rule is:
Every schema change must be compatible with both the currently deployed code and the code you are about to deploy.
A destructive change — dropping a column, renaming it, adding a NOT NULL constraint, tightening a type — breaks that rule by definition. Expand and contract splits the destructive change into a sequence of individually safe ones.
The four phases
Take a common request: users.name must become first_name and last_name.
- Expand. Add the new columns, nullable, no constraints. Old code ignores them; new code has not shipped yet. Safe.
- Dual write / backfill. Deploy code that writes both shapes, then backfill history in batches off the request cycle.
- Migrate reads. Deploy code that reads the new columns only. Still writing both.
- Contract. Once no deployed code and no queued job touches the old column, stop writing it, then drop it.
Four releases, four migrations, zero downtime. The discipline is refusing to collapse them into one "clean" pull request.
Phase 1 — expand
php artisan make:migration add_split_name_columns_to_users_table
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']);
});
}
Adding a nullable column with no default is an instant, metadata-only operation on MySQL 8 and PostgreSQL 11+. This is the cheapest migration you will ever run.
Phase 2 — dual write
Keep the model as the single place the two shapes are reconciled. A mutator is enough for a rename:
protected function name(): Attribute
{
return Attribute::make(
set: function (string $value) {
[$first, $last] = array_pad(explode(' ', trim($value), 2), 2, '');
return [
'name' => $value,
'first_name' => $first,
'last_name' => $last,
];
},
);
}
Anything that bypasses Eloquent — DB::table()->update(), bulk upserts, raw SQL in reports — has to be found and updated too. grep -r "DB::table" app/ before you start, not after.
Phase 2b — backfill without locking the table
Do not put the backfill in the migration. A migration that walks ten million rows inside a deploy step blocks the deploy, blocks the lock, and cannot be resumed if it dies halfway. Put it in a queued, chunked, idempotent command:
class BackfillUserNames extends Command
{
protected $signature = 'users:backfill-names {--chunk=1000} {--sleep=0.1}';
public function handle(): int
{
User::query()
->whereNull('first_name')
->orderBy('id')
->chunkById((int) $this->option('chunk'), function ($users) {
foreach ($users as $user) {
[$first, $last] = array_pad(explode(' ', trim($user->name), 2), 2, '');
User::withoutTimestamps(fn () => $user->forceFill([
'first_name' => $first,
'last_name' => $last,
])->saveQuietly());
}
usleep((int) ($this->option('sleep') * 1_000_000));
});
return self::SUCCESS;
}
}
Four details matter more than the code:
chunkById, notchunk. Offset pagination over a table you are mutating skips rows.whereNull('first_name')makes the command resumable and re-runnable. Run it twice, get the same result.saveQuietly()/withoutTimestampskeeps the backfill from firing observers, dispatching events, touchingupdated_atand invalidating every cache key you own.- The sleep is your replication lag valve. On a read-replica setup, watch
Seconds_Behind_Master(MySQL) orpg_stat_replication(PostgreSQL) while it runs and raise the sleep if lag climbs.
For very large tables, use Bus::batch() so progress is visible in Horizon and a failure cancels cleanly:
$batches = User::query()->whereNull('first_name')->min('id');
// dispatch one BackfillChunk job per id range, then:
Bus::batch($jobs)->name('backfill-user-names')->allowFailures()->dispatch();
Phase 3 — migrate reads
Only once the backfill reports zero remaining rows:
Schema::table('users', function (Blueprint $table) {
$table->index(['last_name', 'first_name']);
});
On PostgreSQL, build it concurrently so writes are not blocked — this needs the migration to opt out of the transaction:
public $withinTransaction = false;
public function up(): void
{
DB::statement('CREATE INDEX CONCURRENTLY users_last_first_idx ON users (last_name, first_name)');
}
Then deploy the code that reads first_name / last_name.
Phase 4 — contract
Wait at least one full deploy cycle and one queue drain. Then:
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropColumn('name');
});
}
If the table is business-critical, rename instead of dropping (name → legacy_name), and drop it a week later. Recovering a dropped column means restoring a backup; recovering a renamed one is another metadata change.
Which DDL is actually safe?
Know what your engine does before you write the migration.
Generally instant / non-blocking:
- Adding a nullable column with no default (MySQL 8, PostgreSQL 11+).
- Adding a column with a constant default (both engines now store this in metadata).
- Dropping an index.
CREATE INDEX CONCURRENTLY(PostgreSQL) orALGORITHM=INPLACE, LOCK=NONE(MySQL/InnoDB).
Blocking or table-rewriting — treat with care:
- Changing a column type (
VARCHAR(255)→TEXT,INT→BIGINT). - Adding
NOT NULLto an existing column. - Adding a foreign key (validates every existing row).
renameColumnon older MySQL versions, and always unsafe from the application's point of view.- Adding a
UNIQUEindex on a large table.
PostgreSQL gives you a two-step escape hatch for constraints:
DB::statement('ALTER TABLE orders ADD CONSTRAINT orders_total_positive CHECK (total >= 0) NOT VALID');
// later, in its own migration:
DB::statement('ALTER TABLE orders VALIDATE CONSTRAINT orders_total_positive');
NOT VALID takes a brief lock; VALIDATE scans the table without blocking writes.
Guard rails
Set a lock timeout so a migration that cannot get its lock fails fast instead of queueing behind a long transaction and blocking every query on the table:
// PostgreSQL
DB::statement("SET lock_timeout = '3s'");
// MySQL
DB::statement('SET SESSION lock_wait_timeout = 3');
Put this in a base migration class your team extends, and retry the deploy rather than letting one ALTER cascade into a pile-up.
Check pending migrations in CI. php artisan migrate:status in a pipeline step, plus a review rule: any migration touching a table over a few million rows needs the expand/contract plan written in the PR description.
Test the migration both ways. Pest makes this cheap:
it('keeps old reads working after the expand migration', function () {
$user = User::factory()->create(['name' => 'Ada Lovelace']);
expect($user->fresh()->first_name)->toBe('Ada')
->and($user->fresh()->last_name)->toBe('Lovelace')
->and($user->fresh()->getRawOriginal('name'))->toBe('Ada Lovelace');
});
Rehearse on a production-sized copy. The only honest answer to "how long will this ALTER take?" comes from running it against a restored snapshot with production row counts, not against a seeded dev database with 50 rows.
A deploy checklist
- Migration is additive and reversible.
- Backfill lives in a resumable queued command, not the migration.
- Old code path still works against the new schema.
- Lock timeout set; long-running transactions checked (
pg_stat_activity/SHOW PROCESSLIST). - Index creation is concurrent/inplace.
- Queue workers restarted after deploy, and old jobs still deserialise.
- Rollback is a code rollback, never a
migrate:rollbackin production.
That last point deserves emphasis: in production, migrate:rollback is not a safety net. It is another schema change, run under stress, against data the down migration has never seen. Your real rollback is deploying the previous release — which works only because every migration you shipped was compatible with it.
Where this gets hard
Splitting a column is the easy example. The same four phases carry much bigger changes — moving a column to a new table, switching a primary key from int to uuid, sharding a table by tenant — but each additional phase multiplies the number of releases and the amount of dual-write code you are carrying at once. Write the plan down, one migration per phase, and resist the pressure to merge them.
If you are staring at a multi-million-row table and a schema change your team has been postponing for a year, get in touch. Planning and running these migrations against live production systems is a regular part of our Laravel performance optimization and application migration work.