Skip to content
Mehedi Hasan Sagor

// blog

Zero‑Downtime Database Migrations in Laravel: A Production‑Ready Playbook

Mehedi Hasan Sagor Oct 9, 2026 4 min read
Zero‑Downtime Database Migrations in Laravel: A Production‑Ready Playbook

Why Database Changes Are a Bottleneck in Production

When a startup scales, even a tiny schema tweak can become a release blocker. A migration that locks a large table for minutes will stall user traffic, trigger timeouts, and erode confidence in your CI/CD pipeline. The goal isn’t just “the migration works” – it’s “the migration works without affecting live users”.

Adopt a Versioned Migration Workflow

Laravel ships with a simple php artisan migrate model, but in a multi‑developer, multi‑environment setup you need more discipline.

  1. Feature branch migrations – Keep migration files in the feature branch that introduces the change. Do not merge the branch until the migration has been run against a staging replica that mirrors production data volume.
  2. Sequential naming – Use a timestamp prefix (2024_10_09_120000_add_status_to_orders.php). This guarantees order across branches and avoids conflicts when two teams add migrations simultaneously.
  3. Idempotent migrations – Write migrations that can be run multiple times without side effects. Example:
public function up()
{
    if (!Schema::hasColumn('orders', 'status')) {
        Schema::table('orders', function (Blueprint $table) {
            $table->string('status')->default('pending');
        });
    }
}
  1. Separate schema and data steps – Keep structural changes (Schema::table) in the up method, and put any heavy data manipulation in a dedicated Artisan command that you run after the migration.

Data Migration Patterns That Avoid Locks

1. Additive Columns

Add a new column with a sensible default or null value. Existing rows remain untouched, so the ALTER TABLE finishes quickly. Later, backfill the column in batches.

Schema::table('users', function (Blueprint $table) {
    $table->boolean('is_premium')->default(false);
});

2. Chunked Backfills

Laravel’s query builder makes chunking easy. Process a few thousand rows per transaction to keep the write lock short.

Artisan::command('users:backfill-premium', function () {
    DB::table('users')->orderBy('id')->chunk(5000, function ($users) {
        foreach ($users as $user) {
            DB::table('users')->where('id', $user->id)->update([
                'is_premium' => $this->determinePremium($user),
            ]);
        }
    });
})->describe('Backfill is_premium column in batches');

3. Rename Columns with a Shadow Column

Direct renameColumn can be expensive on MySQL. Instead:

  1. Add a new column (new_status).
  2. Deploy code that reads/writes the new column while the old column still exists.
  3. Run a background job to copy data from status to new_status in chunks.
  4. Once the job finishes, drop the old column in a second, low‑traffic window.

4. Use Database‑Specific Features

PostgreSQL supports ALTER TABLE ... ADD COLUMN ... DEFAULT without a table rewrite. If you’re on Postgres, leverage that to avoid a full table scan.

Testing, Monitoring, and Rollback Plans

  1. Pre‑flight test on a production‑size replica – Clone the production database (or a recent snapshot) into a staging environment. Run the migration and the backfill command; measure execution time and lock duration.
  2. Automated smoke tests – After the migration, hit a handful of critical API endpoints (/api/orders, /api/users/me) to ensure the new column is readable and writable.
  3. Feature flag the new code path – Wrap reads/writes to the new column behind a flag (e.g., config('features.new_status')). Deploy the flag disabled, verify stability, then flip it on.
  4. Rollback script – Every migration should have a matching down that reverses the schema change and a companion Artisan command that reverts the data back if needed. Keep these scripts version‑controlled.
  5. Observability – Log the start and end timestamps of each chunk, and expose a Prometheus gauge like migration_backfill_seconds_total. Alert if a chunk exceeds a threshold (e.g., 30 seconds).

Release Checklist for Zero‑Downtime Migrations

  • [ ] Migration file added to feature branch, timestamp‑ordered.
  • [ ] Migration is idempotent (Schema::hasColumn guard).
  • [ ] Separate backfill command created and tested on staging replica.
  • [ ] Feature flag controlling new column access is present and default‑off.
  • [ ] Load test the backfill on a replica with realistic row count.
  • [ ] Deploy migration only (no code change) during a low‑traffic window.
  • [ ] Run backfill command in production, monitor chunk duration.
  • [ ] Once backfill completes, deploy code that enables the feature flag.
  • [ ] After a safe period, schedule a second migration to drop the old column.
  • [ ] Verify rollback scripts work on a fresh clone.

By treating schema evolution as a first‑class piece of your CI/CD pipeline, you eliminate the “migration‑day panic” that many startups experience. The pattern of additive changes, chunked backfills, and feature‑flagged code gives you predictable deploy windows, keeps latency low, and lets you ship database upgrades with the same confidence you have in your application code.

Takeaway

Next time you need to change a table that holds millions of rows, resist the urge to run a single php artisan migrate. Break the change into an add‑only migration, a background backfill, and a feature‑flagged rollout. Test each piece on a production‑sized replica, monitor chunk latency, and keep a clear rollback path. The result is a zero‑downtime migration that scales with your business, not against it.

  • Laravel
  • Devops
  • Database
  • Migrations