Tech Verse Logo
Enable dark mode
Zero-Downtime Laravel Migrations in Postgres

Zero-Downtime Laravel Migrations in Postgres

Md. Mostafijur RahmanMMd. Mostafijur Rahman

Md. Mostafijur Rahman

5 min read

The Table Lock Problem in PostgreSQL

Running php artisan migrate on a production table with five million rows can bring down your app in seconds. PostgreSQL takes an ACCESS EXCLUSIVE lock when modifying table structures in specific ways. While PostgreSQL 11 and newer allow adding columns with constant default values instantly without rewriting the entire table, other operations still stall incoming queries. If a web worker attempts to write to that table while the migration waits on a lock, your HTTP request pool fills up, connections exhaust, and PHP-FPM starts dropping 502 errors.

In Laravel 12 running on PHP 8.3, migrations execute sequentially in single transactions unless explicitly told otherwise. If one statement gets stuck waiting for a table lock, every subsequent migration statement halts. Your application thread pool dries up in about three seconds.

Setting Lock Timeouts in Laravel Migrations

By default, PostgreSQL will wait indefinitely to acquire an ACCESS EXCLUSIVE lock. If a long-running reporting query is scanning the table, your migration statement queue hangs until that query finishes. Meanwhile, every incoming app query piles up behind your migration statement.

You can break this cascade by configuring a strict lock_timeout. If the migration can't acquire the lock in 2000 milliseconds, PostgreSQL kills the migration immediately instead of letting web requests stack up behind it. Here is how you enforce this inside a Laravel schema builder statement:

use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Database\Schema\Blueprint;

return new class extends Migration
{
    public function up(): void
    {
        // Fail fast if lock cannot be acquired within 2 seconds
        DB::statement('SET lock_timeout = "2000ms"');

        Schema::table('users', function (Blueprint $table) {
            $table->string('display_name')->nullable();
        });
    }

    public function down(): void
    {
        DB::statement('SET lock_timeout = "2000ms"');

        Schema::table('users', function (Blueprint $table) {
            $table->dropColumn('display_name');
        });
    }
};

If this migration fails due to a timeout, your deployment script aborts, but your API continues serving traffic normally. You retry the deployment during a lower-traffic window or kill the long-running SELECT query holding the lock.

The Expand-Contract Pattern

Zero downtime deployments require running old code and new code simultaneously against the same database schema. When deploying a Next.js 16 frontend backed by Laravel 12, your rolling deployment means some web servers execute the new code while others still run the previous commit.

If you rename a column directly or add a NOT NULL column without a default value, the old application version breaks instantly. You must follow three separate deployment phases:

  1. Expand: Add the new column as nullable or with a non-blocking default value. Deploy app code that writes to both old and new columns.
  2. Backfill: Migrate historical data from the old structure to the new structure in background worker jobs.
  3. Contract: Update app code to read exclusively from the new column, then drop the old column in a subsequent release.

Safely Backfilling Data Without Melting the CPU

Never run massive UPDATE users SET ... queries inside a migration file. A single query updating one million rows creates a massive database transaction log, locks affected rows, and causes read/write replication lag on standby nodes. On an AWS Aurora PostgreSQL instance, updating 100,000 rows in one sweep can push CPU usage from 12% to 98% and lag read replicas by 45 seconds.

Instead, write an Artisan command that processes records in deterministic chunks using chunkById. This keeps transactions tiny, allows locks to release instantly, and prevents memory leaks inside PHP CLI workers.

namespace App\Console\Commands;

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

class BackfillUserDisplayNames extends Command
{
    protected $signature = 'db:backfill-display-names {--chunk=1000}';
    protected $description = 'Backfills display_name column in chunks without locking users table';

    public function handle(): int
    {
        $chunkSize = (int) $this->option('chunk');
        $count = 0;

        $this->info("Starting backfill in chunks of {$chunkSize}...");

        DB::table('users')
            ->whereNull('display_name')
            ->whereNotNull('first_name')
            ->chunkById($chunkSize, function ($users) use (&$count) {
                foreach ($users as $user) {
                    $displayName = trim($user->first_name . ' ' . $user->last_string_name);
                    
                    DB::table('users')
                        ->where('id', $user->id)
                        ->update(['display_name' => $displayName]);
                }

                $count += $users->count();
                $this->info("Processed {$count} records...");
                
                // Sleep 50ms between chunks to let Postgres breathe
                usleep(50000);
            });

        $this->info('Backfill complete.');
        return Command::SUCCESS;
    }
}

Notice the usleep(50000) call between iterations. Yielding 50 milliseconds gives PostgreSQL CPU time to clean up dead tuples via autovacuum and allows high-priority web request transactions to acquire locks without queuing up.

Handling Schema Caching and Dropping Columns

Dropping a column is where teams get burned. You migrated the data, pointed the new Laravel code to display_name, and verified everything works. You write a migration with $table->dropColumn('first_name'), push to production, and instantly watch error trackers light up with SQL errors like Column not found: 7 ERROR: column "first_name" of relation "users" does not exist.

This happens because Eloquent generates queries like select * from users under the hood unless you explicitly select specific attributes. Furthermore, if you are running Octane or long-lived PHP processes, ORM model instances cache the database schema in memory upon boot.

To safely drop a column, complete these two deployment steps in order:

First step: Ignore the column in Eloquent model definitions before dropping it from PostgreSQL. In Laravel 12, you can inform Eloquent to ignore a column using the model's static attributes or query building filters.

namespace App\Models;

use Illuminate\Database\Eloquent\Model;

class User extends Model
{
    // Prevent Eloquent from requesting or writing to deleted schema fields
    protected $hidden = [
        'first_name',
    ];
}

Second step: In your next deployment, after all PHP-FPM processes or Octane workers have reloaded with the new code, execute the migration dropping the database column.

Adding Indexes Concurrently in Postgres

Standard index creation in PostgreSQL (CREATE INDEX) locks the table against all writes until the entire index build finishes. On a table with 20 million rows, this build process can take 8 to 15 minutes. During that entire window, every INSERT, UPDATE, and DELETE statement fails or hangs.

PostgreSQL provides CREATE INDEX CONCURRENTLY to build indexes without blocking writes. It requires scanning the table twice and takes slightly longer, but your users never notice it running. However, Postgres cannot run concurrent index builds inside a transaction block.

Laravel supports non-transactional migrations specifically for this reason. Set public $withinTransaction = false; on your migration class before invoking indexConcurrently():

use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\Schema;
use Illuminate\Database\Schema\Blueprint;

return new class extends Migration
{
    // Disable automatic transaction wrapper for concurrent index creation
    public bool $withinTransaction = false;

    public function up(): void
    {
        Schema::table('users', function (Blueprint $table) {
            $table->indexConcurrently(['email', 'created_at'], 'idx_users_email_created');
        });
    }

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

If a concurrent index build fails midway—due to a dropped connection or dead lock—PostgreSQL leaves behind an INVALID index. You must manually drop that invalid index before re-running the migration, or php artisan migrate will throw a duplicate index name error on subsequent attempts.

Building resilient database pipelines isn't about avoiding schema changes; it's about treating schema changes as multi-phase deployment pipelines. Set strict lock timeouts, execute backfills in low-impact background chunks, and decouple column deletion from application code deployment.

Md. Mostafijur RahmanMMd. Mostafijur Rahman

WRITTEN BY

Md. Mostafijur Rahman

    Latest Posts

    View All

    Laravel Signed URLs and One-Time Download Links

    Laravel Signed URLs and One-Time Download Links

    Laravel Timezone Handling: UTC, Users, and DST Bugs

    Laravel Timezone Handling: UTC, Users, and DST Bugs

    Laravel Login Throttle: Rate Limiting and Credential Defense

    Laravel Login Throttle: Rate Limiting and Credential Defense

    Building Honest Health Check Endpoints in Laravel

    Building Honest Health Check Endpoints in Laravel

    Writing Production-Ready Laravel Artisan Commands

    Writing Production-Ready Laravel Artisan Commands

    Testing Mail in Laravel: Mailables and Assertions

    Testing Mail in Laravel: Mailables and Assertions

    Solving Low-Priority Queue Starvation in Laravel

    Solving Low-Priority Queue Starvation in Laravel

    Realistic Laravel Seeders with States and Relations

    Realistic Laravel Seeders with States and Relations

    Fixing Laravel Broadcasting Auth and 403 Errors

    Fixing Laravel Broadcasting Auth and 403 Errors

    Laravel Multi Tenancy: Single vs Multi Database

    Laravel Multi Tenancy: Single vs Multi Database