Zero-downtime database migrations in Laravel
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:
- Pre-deploy migrations: Schema changes that are backward-compatible with current code
- Application deployment: Rolling update to new code version
- Post-deploy commands: Data backfills, cache warming, verification tasks
- Cleanup migrations: Removal of deprecated columns (scheduled for subsequent release)
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:
- Release N: Stop writing to old column, ensure new code ignores it
- Release N+1: Rename old column with
zzz_deprecated_prefix to catch any lingering references - Monitoring period: Watch error logs for queries referencing the renamed column
- Release N+2: Actually drop the column
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.