Zero-Downtime Database Migrations in Laravel: The Expand and Contract Pattern

A step-by-step guide to Laravel zero-downtime migrations with the expand and contract pattern: nullable expands, batched backfills, and flagged reads.

Steven Richardson
Steven Richardson
· 14 min read

Renaming users.name to users.full_name looks like a one-line migration. In production it is a self-inflicted outage: php artisan migrate runs during the deploy, and for the seconds or minutes between the migration finishing and the new release going live, the currently-running code is still selecting a name column that no longer exists. Every request that touches a user throws a column not found error until the new code catches up.

Laravel zero-downtime migrations solve this by refusing to make any single change that both the old and new releases can't tolerate. The technique is the expand and contract pattern, and this guide carries one concrete rename — name to full_name — end to end: an additive expand migration, a dual-write, a batched backfill, a concurrent index, a feature-flagged read switch, and finally a contract migration that drops the old column days later. Every phase keeps the old structure intact, so at no point is the live code reading something that isn't there, and at every point you have a rollback that doesn't lose writes.

Add the new column as nullable in an expand migration#

The expand phase adds new structure without touching or removing anything the running release depends on. For our rename, that means a single additive migration that creates a full_name column that is nullable. Nullable is not optional here: a NOT NULL column with no default explodes the instant the old code inserts a row without knowing the column exists, which reintroduces exactly the downtime you are trying to avoid.

<?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): void {
            $table->string('full_name')->nullable()->after('name');
        });
    }

    public function down(): void
    {
        Schema::table('users', function (Blueprint $table): void {
            $table->dropColumn('full_name');
        });
    }
};

Adding a nullable column with no default is cheap on both major engines, and it leans on nothing exotic — just the standard Laravel schema builder. PostgreSQL 11+ records it as a catalogue change and never rewrites the table. MySQL 8 uses ALGORITHM=INSTANT for a nullable column added at the end of the row, so it also skips the rewrite. Both the old release (which ignores full_name) and the new release (which will populate it) run happily against this schema, which is the whole point of the expand phase.

Deploy the expand migration ahead of the code change#

Deploy ordering is the entire game, so run the expand migration in its own deploy, before any application code references full_name. The migration must be safe against the release that is currently serving traffic — and because the column is additive and nullable, it is. Nothing that is running notices the new column, so there is no window where live code meets a schema it wasn't built for.

In a rolling deploy, old and new containers run side by side for a while, which is fine precisely because the schema tolerates both. Run the migration with php artisan migrate --force as a discrete step, and confirm your orchestrator only shifts traffic once the app is healthy — the same discipline covered in Laravel health checks for Kubernetes readiness and liveness probes. Whether you deploy on Forge or a serverless platform changes the mechanics but not the rule; if you are weighing those up, Laravel Vapor vs Laravel Forge in 2026 breaks down how each handles the migrate step. The golden rule: schema changes ship in a deploy that contains only additive changes, never bundled with the code that depends on them.

Dual-write to the old and new columns during the transition#

A backfill only fixes rows that exist at the moment it runs. Between the expand deploy and the eventual contract deploy — which may be days — the application keeps inserting and updating users, and every one of those writes must populate both columns or the two will silently drift apart. The cleanest place to guarantee that is a model observer that mirrors whichever column changed onto the other.

<?php

namespace App\Observers;

use App\Models\User;

class UserFullNameObserver
{
    public function saving(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;
        }
    }
}

Register it with the #[ObservedBy] attribute so the intent lives on the model:

use App\Observers\UserFullNameObserver;
use Illuminate\Database\Eloquent\Attributes\ObservedBy;

#[ObservedBy([UserFullNameObserver::class])]
class User extends Authenticatable
{
    // ...
}

Now the old release (which only writes name) and the new release (which writes full_name) both leave the row consistent, because the observer fills whichever side was left blank. This is the deploy that follows the expand migration: schema first, then the code that keeps both columns in sync. Observers are also a good place to enforce invariants — if you lean on them, pair them with Eloquent strict mode so a missing attribute fails loudly in development instead of quietly in production. One caveat: saving covers Eloquent writes, not raw DB::table('users')->update(...) queries, so audit for query-builder writes that bypass the model.

Backfill existing rows in a batched queued job#

With new writes handled, copy the existing rows across — in small, throttled batches, and never from inside the migration. A migration that runs UPDATE users SET full_name = name across ten million rows holds one enormous transaction, blocks the deploy pipeline while it runs, and will happily blow past your statement or deploy timeout. Put the backfill in an Artisan command you run on a worker box instead, using chunkById so it pages by primary key and stays resumable.

<?php

namespace App\Console\Commands;

use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;

class BackfillUserFullName extends Command
{
    protected $signature = 'users:backfill-full-name {--chunk=1000} {--sleep=100}';

    protected $description = 'Copy users.name into users.full_name in safe, resumable batches.';

    public function handle(): int
    {
        $sleepMicroseconds = ((int) $this->option('sleep')) * 1000;
        $total = 0;

        DB::table('users')
            ->whereNull('full_name')
            ->whereNotNull('name')
            ->orderBy('id')
            ->chunkById((int) $this->option('chunk'), function ($users) use ($sleepMicroseconds, &$total): void {
                DB::table('users')
                    ->whereIn('id', collect($users)->pluck('id'))
                    ->update(['full_name' => DB::raw('name')]);

                $total += count($users);
                $this->info("Backfilled {$total} rows...");

                usleep($sleepMicroseconds);
            });

        $this->info("Done. Backfilled {$total} users.");

        return self::SUCCESS;
    }
}

Three properties make this safe. It is idempotent: the whereNull('full_name') filter means a second run only touches rows that are still empty, so re-running after a crash resumes from where it stopped rather than redoing work. It is throttled: the --sleep pause between batches gives replicas time to catch up and keeps IO off the redline. And it is chunked by id, which is why you must use chunkById and not chunk — modifying rows inside a plain chunk loop shifts the offsets and silently skips records.

For a table large enough that the backfill runs for hours, dispatch it onto your queue and let a dedicated worker chew through it; scaling Laravel queues in production covers the worker topology, and if the backfill competes with customer-facing jobs, rate-limiting and backing off queued jobs at the job level keeps it polite. If you would rather fan the work out into many small jobs with a completion callback, Laravel queue chains vs batches explains which shape fits a resumable backfill. Whatever you choose, the backfill runs after the dual-write is live, so no row created mid-backfill is missed.

Create indexes without locking large tables#

If the new column will be queried or sorted — and a full_name you display and search almost certainly will be — it needs an index. On a small table you can add it in the expand migration and move on. On a large one, a naive CREATE INDEX takes a lock that blocks writes for the whole build, which is its own outage, so build it without holding that lock.

PostgreSQL offers CREATE INDEX CONCURRENTLY, but it cannot run inside a transaction, and Laravel wraps migrations in one by default. Set public $withinTransaction = false on the migration and issue the statement raw:

<?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 INDEX CONCURRENTLY idx_users_full_name ON users (full_name)');
    }

    public function down(): void
    {
        DB::statement('DROP INDEX CONCURRENTLY IF EXISTS idx_users_full_name');
    }
};

On MySQL 8 the equivalent is online DDL, which lets reads and writes continue while the index builds. Be explicit about it so a surprise lock never sneaks in:

DB::statement('ALTER TABLE users ADD INDEX idx_users_full_name (full_name), ALGORITHM=INPLACE, LOCK=NONE');

If MySQL cannot satisfy LOCK=NONE for a given operation it errors instead of silently locking, which is exactly the feedback you want in a migration. For truly enormous tables on either engine, an external tool such as pt-online-schema-change or gh-ost copies the table in the background, but for adding an index to a normally-sized production table the built-in concurrent and online paths are plenty.

Switch reads to the new column behind a feature flag#

Both columns are now populated and staying in sync, so you can flip reads from name to full_name — gradually, behind a feature flag, decoupled from any deploy. A flag lets you enable the new read path for one percent of users, watch your dashboards, ramp to a hundred percent, and switch it off in seconds if something looks wrong, all without shipping code or rolling anything back. Install Laravel Pennant and define the flag:

use App\Models\User;
use Illuminate\Support\Lottery;
use Laravel\Pennant\Feature;

// In a service provider's boot() method:
Feature::define('use-full-name', fn (User $user) => Lottery::odds(1, 100));

Lottery::odds(1, 100) resolves true for roughly one percent of users, and because Pennant persists a resolved value per scope, a given user stays on one side of the flag rather than flickering between reads. Consume it wherever the display name is read:

$displayName = Feature::for($user)->active('use-full-name')
    ? $user->full_name
    : $user->name;

Or directly in Blade with the @feature directive:

@feature('use-full-name')
    {{ $user->full_name }}
@else
    {{ $user->name }}
@endfeature

To ramp, widen the odds (or switch to a full rollout) and let it soak at each step. Because dual-write is still running underneath, users on the old read path and the new read path see identical data — the flag only changes which column you read, never what the value is. This is the reason expand/contract is so much calmer than a big-bang cutover, and it is worth reading Laravel Pennant feature flags in practice for the rollout, scoping, and purging details before you rely on it in anger.

Test the migration path before you contract#

Before you drop anything, prove that the two columns agree and that both code paths behave. This is where expand/contract earns its keep: the old column is still present as a reference, so you can assert consistency in CI and against a production-like dataset while a mistake is still cheap to fix. Write the guarantees down as tests.

<?php

use App\Models\User;
use Laravel\Pennant\Feature;

it('keeps full_name in sync when name changes', function () {
    $user = User::factory()->create(['name' => 'Ada Lovelace']);

    $user->update(['name' => 'Ada King']);

    expect($user->fresh()->full_name)->toBe('Ada King');
});

it('reads the new column only when the flag is active', function () {
    $user = User::factory()->create([
        'name' => 'Grace Hopper',
        'full_name' => 'Grace Hopper',
    ]);

    Feature::for($user)->activate('use-full-name');

    expect(Feature::for($user)->active('use-full-name'))->toBeTrue();
});

Alongside the unit tests, run a consistency probe against real data to catch any row that drifted — anything the query returns is a bug to fix before the old column disappears:

use Illuminate\Support\Facades\DB;

$drift = DB::table('users')
    ->whereNotNull('name')
    ->where(function ($query) {
        $query->whereNull('full_name')
            ->orWhereColumn('full_name', '!=', 'name');
    })
    ->count();

Schema changes behave differently across engines and versions, so run this suite on the same database you deploy to. If your test matrix only ever sees SQLite, GitHub Actions matrix testing for Laravel across PHP and database combos shows how to exercise MySQL and PostgreSQL in CI so a Postgres-only locking quirk doesn't surface for the first time in production.

Drop the old column in a follow-up contract migration#

Once the flag has been at a hundred percent long enough that you trust it, every read comes from full_name, and every write keeps both columns in sync, you can finally remove the old column. Contract is a separate deploy, days or weeks after expand — and it comes in a specific order: first ship a code deploy that stops referencing name entirely (remove the dual-write branch that reads name, delete the flag check, read and write full_name only), and only once that code is live do you run the migration that drops 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): void {
            $table->dropColumn('name');
        });
    }

    public function down(): void
    {
        Schema::table('users', function (Blueprint $table): void {
            $table->string('name')->nullable();
        });
    }
};

With the old column gone, tighten the new one so the schema tells the truth about your data. On MySQL, remember that ->change() must restate every attribute the column already had, or the ones you omit get dropped:

Schema::table('users', function (Blueprint $table): void {
    $table->string('full_name')->nullable(false)->change();
});

If full_name is indexed and huge, promoting it to NOT NULL can itself lock on some engines, so treat that constraint change with the same online-DDL care as the index build above — or apply it in yet another small, separate step.

Plan your rollback and remove the scaffolding#

The reason to spread one rename across a week of small deploys is that every phase before the contract has a clean escape hatch, and it is worth knowing exactly what each one is. The expand migration is inert — the nullable column changes nothing, and worst case php artisan migrate:rollback drops it. The backfill is idempotent, so a failure just means re-running the command. The read switch is the safest of all: disabling the Pennant flag reverts every user to the old column instantly, with no deploy and no data loss, because dual-write kept name current the whole time. Only the contract is irreversible, which is why it goes last, only after a soak, and only with a fresh backup in hand.

Watch the rollout while it happens rather than after: error rates, query latency on the new column, and backfill throughput all belong on a dashboard, and production observability in Laravel with Pulse, Nightwatch, and OpenTelemetry covers wiring those up. Once the contract deploy has settled, clean up the scaffolding you built to get here: delete the observer, the use-full-name flag definition (php artisan pennant:purge use-full-name), and the backfill command, so the next engineer doesn't mistake transitional machinery for permanent design. From here, two directions are worth your time — tuning the workers that ran your backfill so long-running jobs don't leak memory, and getting comfortable enough with feature flags that flag-gated schema changes become your default rather than a special occasion. Do this two or three times and the "scary" migration stops being scary — it becomes a checklist.

FAQ#

What is the expand and contract pattern in Laravel migrations?

Expand and contract is a way of splitting one breaking schema change into a sequence of individually safe deploys. You first expand the schema by adding new structure (a nullable column, a new table, an index) without removing or renaming anything the running code reads. You migrate data and switch behaviour over gradually, and only much later do you contract by dropping the old structure. At every intermediate step both the old and new versions of your application work against the schema, which is what removes the downtime.

How do I run a Laravel migration without downtime?

Never bundle a breaking change with the code that depends on it. Ship additive, backward-compatible migrations in their own deploy, keep them fast (nullable columns, no table rewrites, concurrent index builds), and move any heavy data work out of the migration and into a queued job or Artisan command. Then change application behaviour behind a feature flag rather than a migration. The migration itself stays trivial and safe against whatever release is currently live.

How do I backfill a large table without locking it?

Do the backfill in batches with chunkById, from a queued job or Artisan command, not from a migration. chunkById pages by primary key so it stays correct even as you update rows in the loop, and a small pause between batches keeps replication lag and disk IO under control. Add a whereNull (or similar) filter so the job only touches rows that still need work, which makes it both idempotent and resumable if it dies halfway through a ten-million-row table.

Should I put a backfill in a Laravel migration or a queued job?

A queued job or Artisan command, almost always. Migrations run synchronously during deploy, inside a transaction, and a long UPDATE there blocks the pipeline, risks a statement timeout, and holds locks for the entire duration. A queued job runs on your own schedule, survives worker restarts, can be throttled and monitored, and can be re-run safely if it fails. Keep the migration to the additive schema change and let a job carry the data.

How do feature flags help with zero-downtime migrations?

Deployment ordering alone can't give you a gradual, instantly reversible cutover — a feature flag can. With the schema already expanded and both columns kept in sync, a flag lets you route a small slice of traffic onto the new read path, verify it, and ramp to full rollout, all without shipping code. If anything misbehaves you disable the flag and every user reverts immediately, with no rollback and no lost writes because the old column was never abandoned. Laravel Pennant provides the flag storage, scoping, and @feature Blade directive for this.

How do I roll back an expand/contract migration safely?

Roll back at the phase you are in, because each one has a different escape hatch. During expand, the nullable column is harmless and migrate:rollback removes it. During backfill, just re-run the idempotent command. During the read switch, disable the feature flag and every user instantly returns to the old column, which dual-write has kept current. The only phase you cannot cleanly undo is contract, since dropping the column destroys data — so you contract last, after a soak period, and only with a verified backup you can restore from.

Can I just rename a column in a Laravel migration?

You can ($table->renameColumn('name', 'full_name')), but it is a trap for a live application. A rename is atomic at the database level, so the instant it runs, the currently-deployed code is querying a column that no longer exists, which is precisely the outage expand/contract avoids. On older setups a rename also required the doctrine/dbal package and could rewrite the table. Treat a rename as "add new, backfill, switch reads, drop old" rather than a single statement, and the downtime disappears.

How long should I wait between the expand and contract deploys?

Long enough that the new path has genuinely proven itself under real traffic — usually days, sometimes weeks, rarely less than a full business cycle for the feature. You want the feature flag at a hundred percent, error dashboards clean, and confidence that no forgotten code path or third-party integration still reads the old column. Contract is the one irreversible step, so there is no prize for rushing it; the old column sitting there costs you almost nothing, while dropping it a day too early can cost you an outage.

Steven Richardson
Steven Richardson

CTO at Digitonic. Writing about Laravel, architecture, and the craft of leading software teams from the west coast of Scotland.