Running php artisan migrate on a local development machine takes less than a second. But when your production application scales to tens of millions of rows across high-traffic relational databases (MySQL, PostgreSQL), executing a standard ALTER TABLE statement can trigger exclusive metadata locks, stall active queries, and bring down your entire application.
Zero-downtime continuous deployment requires a fundamental shift in how schema changes are designed and shipped. In this guide, we break down the industry-standard Expand and Contract Pattern (Parallel Change), analyze how database engines lock tables, and implement a battle-tested blueprint for renaming columns, changing data types, adding non-nullable columns, and creating concurrent indices in Laravel without dropping a single user request.
The Anatomy of Migration Table Locks
Why do migrations cause outages in production? Relational database engines require exclusive metadata locks (MDL in MySQL, AccessExclusiveLock in PostgreSQL) when modifying table definitions:
| Operation | Direct Naive Approach | Production Consequence at Scale |
|---|---|---|
| Renaming a Column | $table->renameColumn('old', 'new'); |
Instant 500 errors for running app servers during the deployment window expecting the old name. |
| Adding Non-Nullable Column with Default | $table->string('status')->default('active'); |
Full table rewrite in older MySQL versions; exclusive lock queuing all incoming read/write traffic. |
| Adding Heavy Composite Index | $table->index(['user_id', 'created_at']); |
Blocks all write operations (INSERT, UPDATE, DELETE) for minutes or hours while the B-Tree is constructed. |
| Dropping a Column | $table->dropColumn('legacy_field'); |
Active background workers or non-restarted PHP-FPM processes fail when referencing the removed field. |
The 4-Phase Expand and Contract Pattern
The Expand and Contract Pattern (also known as Parallel Run) decouples schema modifications across multiple phased deployments so that both the previous version of your code and the new version of your code can run simultaneously without errors.
The 4 Sequential Phases:
- Phase 1 (Expand): Add the new column as nullable, or create the new table structure.
- Phase 2 (Dual Write & Backfill): Deploy application code that reads from the old column, writes to both old and new columns, and runs an asynchronous chunked background job to backfill historical records.
- Phase 3 (Switch Reads): Switch the application to read and write exclusively from the new column.
- Phase 4 (Contract): Drop the old deprecated column and remove legacy compatibility layers.
Practical Scenario: Safely Renaming a Column
Suppose we have an orders table with a column cost_cents that we need to rename to total_amount_cents.
Phase 1: Expand Migration
<?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('orders', function (Blueprint $table) {
// Add the new column as NULLABLE first
$table->unsignedBigInteger('total_amount_cents')->nullable()->after('cost_cents');
});
}
public function down(): void
{
Schema::table('orders', function (Blueprint $table) {
$table->dropColumn('total_amount_cents');
});
}
};
Phase 2: Eloquent Model Dual-Writing
In your Eloquent model, maintain synchronization between both fields so un-migrated code paths and new code paths stay in perfect sync:
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class Order extends Model
{
protected $fillable = [
'user_id',
'cost_cents', // Legacy column
'total_amount_cents', // New column
];
/**
* Dual-write hook to ensure parity across releases.
*/
protected static function booted(): void
{
static::saving(function (Order $order) {
if ($order->isDirty('total_amount_cents') && !$order->isDirty('cost_cents')) {
$order->cost_cents = $order->total_amount_cents;
} elseif ($order->isDirty('cost_cents') && !$order->isDirty('total_amount_cents')) {
$order->total_amount_cents = $order->cost_cents;
}
});
}
}
Phase 2.5: Chunked Background Backfill Job
Never run a raw UPDATE orders SET total_amount_cents = cost_cents across 20 million rows in a migration. That query will lock the entire table and overflow your database rollback segment. Instead, process historical records in small cursor batches using a queued Artisan command:
<?php
namespace App\Console\Commands;
use App\Models\Order;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
class BackfillOrderAmountCommand extends Command
{
protected $signature = 'orders:backfill-amounts {--chunk=1000}';
protected $description = 'Backfill total_amount_cents from legacy cost_cents in safe chunks';
public function handle(): int
{
$chunkSize = (int) $this->option('chunk');
$this->info('Starting safe non-blocking backfill...');
Order::whereNull('total_amount_cents')
->whereNotNull('cost_cents')
->chunkById($chunkSize, function ($orders) {
DB::transaction(function () use ($orders) {
foreach ($orders as $order) {
$order->updateQuietly([
'total_amount_cents' => $order->cost_cents,
]);
}
});
// Small micro-sleep to allow primary DB replication lag to drain
usleep(20000); // 20ms
});
$this->info('Backfill completed successfully.');
return self::SUCCESS;
}
}
Phase 3 & 4: Switch Reads & Contract
Once all historical data is backfilled (Order::whereNull('total_amount_cents')->count() === 0), ship your updated application code reading strictly from total_amount_cents. In the subsequent deployment release, execute the contract migration:
<?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('orders', function (Blueprint $table) {
// Safely drop the legacy column now that nothing references it
$table->dropColumn('cost_cents');
});
}
public function down(): void
{
Schema::table('orders', function (Blueprint $table) {
$table->unsignedBigInteger('cost_cents')->nullable();
});
}
};
Zero-Downtime Indexing (PostgreSQL & MySQL)
Creating an index on a large table in PostgreSQL with standard $table->index() will acquire a ShareLock, preventing all writes to the table. Instead, execute raw concurrent index creation outside a transactional block:
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
// PostgreSQL transactions prevent CONCURRENTLY operations
public $withinTransaction = false;
public function up(): void
{
DB::statement('CREATE INDEX CONCURRENTLY IF NOT EXISTS orders_user_status_idx ON orders (user_id, status)');
}
public function down(): void
{
DB::statement('DROP INDEX CONCURRENTLY IF EXISTS orders_user_status_idx');
}
};
Production Zero-Downtime Checklist
- ✓ Never rename columns in-place: Always use Expand (Add new) → Dual Write → Backfill → Contract (Drop old).
- ✓ Add columns as nullable: Adding non-nullable columns without defaults locks tables; make them nullable first, backfill data, then add constraints.
- ✓ Batch large backfills: Use
chunkById()with pacing sleeps to prevent replication lag on read replicas. - ✓ PostgreSQL CONCURRENTLY: Always use
public $withinTransaction = false;when building indices concurrently in Postgres. - ✓ Deploy before contracting: Never drop a database column until the application code that references it has been deployed and all active background workers have restarted.