← Back to blog

Zero-downtime database migrations in Laravel

2026-05-13

Zero-Downtime Database Migrations in Laravel

Deploying database changes without interrupting your users is a critical skill for any Laravel developer running production applications. A single poorly planned migration can bring down your entire platform, turning a routine deploy into an emergency rollback situation. This guide provides a battle-tested approach to zero-downtime database migrations using Laravel, PostgreSQL, and disciplined deployment practices.

Why Zero-Downtime Migrations Matter

Traditional migration strategies often involve stopping the application, running migrations, then restarting. This creates maintenance windows that alienate users and cost revenue. Modern applications demand continuous availability, which means your database schema must evolve while traffic flows uninterrupted.

The core challenge is that your application code and database schema are never perfectly synchronized during deployment. At any moment, some application servers may be running old code while others run new code, and both must function correctly against the same database. Understanding this reality is fundamental to designing safe migrations.

The Expand-Contract Pattern

The expand-contract pattern is the foundation of safe schema evolution. Instead of changing existing structures directly, you expand the schema by adding new elements, migrate data gradually, then contract by removing old elements once everything stabilizes.

Consider a typical rename operation. A naive approach:

// DANGEROUS: Causes errors during deployment
Schema::table('users', function (Blueprint $table) {
    $table->renameColumn('email', 'email_address');
});

During deployment, servers running old code expect email while new code expects email_address. Both cannot work simultaneously. The expand-contract approach solves this:

// Step 1: Expand - Add new column (Release 1)
Schema::table('users', function (Blueprint $table) {
    $table->string('email_address')->nullable();
});
// Backfill data (run once, can be done in chunks)
DB::table('users')
    ->whereNull('email_address')
    ->update(['email_address' => DB::raw('email')]);
// Step 2: Update application to write both columns (Release 2)
// In your model or controller:
$user->email = $request->email;
$user->email_address = $request->email;
$user->save();
// Step 3: Switch reads to new column, make it non-nullable (Release 3)
Schema::table('users', function (Blueprint $table) {
    $table->string('email_address')->nullable(false)->change();
    $table->dropColumn('email');
});

This three-release cycle ensures every version of your code can function. Patience with this process eliminates the risk of breaking production.

Handling Indexes and Constraints

PostgreSQL acquires heavy locks when creating certain indexes and constraints. Large tables can block writes for minutes or hours, causing application timeouts and queue backups.

Always use CONCURRENTLY for index creation in PostgreSQL:

// Instead of this blocking operation:
Schema::table('orders', function (Blueprint $table) {
    $table->index('status');
});

// Use raw SQL for concurrent index creation
DB::statement('CREATE INDEX CONCURRENTLY idx_orders_status ON orders(status)');

Concurrent indexes have tradeoffs. They take longer to build and can fail if duplicates exist when creating unique indexes. Plan for these scenarios by validating data beforehand and monitoring index creation progress.

For foreign keys, create the index first, then add the constraint:

// Step 1: Create supporting index concurrently
DB::statement('CREATE INDEX CONCURRENTLY idx_orders_user_id ON orders(user_id)');

// Step 2: Add foreign key constraint (lighter lock with existing index)
Schema::table('orders', function (Blueprint $table) {
    $table->foreign('user_id')
          ->references('id')
          ->on('users');
});

Managing Long-Running Migrations

Laravel migrations execute synchronously during deployment by default. Long operations risk deployment timeouts and failed health checks. Separate heavy data migrations from schema changes using Laravel's command infrastructure.

Create dedicated commands for backfills:

php artisan make:command BackfillOrderTotals
class BackfillOrderTotals extends Command
{
    protected $signature = 'backfill:order-totals {--chunk=1000}';
    
    public function handle()
    {
        Order::query()
            ->whereNull('total_cents')
            ->chunkById($this->option('chunk'), function ($orders) {
                foreach ($orders as $order) {
                    $order->updateQuietly([
                        'total_cents' => $this->calculateTotal($order),
                    ]);
                }
                
                $this->info("Processed chunk, last ID: {$orders->last()->id}");
            });
    }
}

Run these commands before or after deployment, never during the critical path. For very large tables, consider using PostgreSQL's pglogical or trigger-based replication for zero-downtime data transformations.

Deployment Pipeline Integration

A robust deployment pipeline separates migration timing from code rollout. Consider this structure for your deployment process:

Implement this in your deployment scripts:

#!/bin/bash
set -euo pipefail

echo "Running pre-deploy migrations..."
php artisan migrate --force

echo "Deploying application code..."
# Your deployment mechanism (Kubernetes rolling update, etc.)

echo "Running post-deploy tasks..."
php artisan optimize
php artisan queue:restart

# Optional: Trigger backfill if needed
# php artisan backfill:order-totals

Critical PostgreSQL-Specific Techniques

Leverage PostgreSQL features that minimize lock contention. Understanding transaction isolation and lock behavior is essential.

For adding columns with defaults, PostgreSQL 11+ provides optimized behavior, but older versions rewrite the entire table. Always check your PostgreSQL version and test migrations against production-like data volumes.

When adding NOT NULL constraints, use a two-step approach to avoid full table scans:

-- Add constraint as NOT VALID (no scan of existing rows)
ALTER TABLE orders ADD CONSTRAINT chk_positive_amount CHECK (amount > 0) NOT VALID;

-- Validate separately (acquires less aggressive lock)
ALTER TABLE orders VALIDATE CONSTRAINT chk_positive_amount;

In Laravel, execute this with DB::statement() since schema builders don't expose these advanced options.

Column Removal Strategy

Dropping columns is the riskiest operation because old code may fail explosively when encountering missing data. Never drop columns immediately after stopping their use.

Instead, follow this timeline:

This defensive approach has saved countless production systems from outages caused by forgotten background jobs, cached queries, or delayed queue workers.

Validation and Testing

Every migration must pass through multiple validation stages. Create a staging environment that mirrors production data volume and connection patterns. Test migrations with realistic load to discover lock contention before users do.

Implement migration dry-runs in your CI pipeline:

// In a CI test or review app
Artisan::call('migrate', ['--pretend' => true]);

While --pretend shows SQL without executing, it's invaluable for reviewing what will actually run.

Additionally, add timing expectations to catch unexpectedly slow migrations:

class AddOrdersIndex extends Migration
{
    public function up()
    {
        $start = now();
        
        DB::statement('CREATE INDEX CONCURRENTLY idx_orders_created_at ON orders(created_at)');
        
        $duration = now()->diffInSeconds($start);
        
        if ($duration > 30) {
            Log::warning('Migration took longer than expected', [
                'migration' => __CLASS__,
                'duration_seconds' => $duration,
            ]);
        }
    }
}

Handling Rollbacks Gracefully

Zero-downtime migrations must also consider rollback scenarios. If a deploy fails after migrations run, your old code must still function. This is why backward-compatibility is non-negotiable.

Design migrations that are reversible without data loss. For complex transformations, create explicit down migrations that preserve information:

public function down()
{
    // Never simply drop new columns without preserving data
    Schema::table('users', function (Blueprint $table) {
        $table->string('legacy_email')->nullable();
    });
    
    DB::table('users')->update([
        'legacy_email' => DB::raw('email_address'),
    ]);
    
    // Now safe to restructure
}

In practice, rolling back production databases is rare and dangerous. Prefer rolling forward with fixes over reverting schema changes.

Conclusion

Zero-downtime database migrations require discipline, planning, and patience. The expand-contract pattern, concurrent PostgreSQL operations, and careful separation of schema changes from data transformations form the backbone of reliable deployment practices.

Treat every migration as a potential production incident waiting to happen. Verify backward compatibility, test at scale, and never rush the contraction phase of schema evolution. Your users will thank you for the seamless experience, and your team will sleep better knowing deploys are routine rather than heroic endeavors.