Master Laravel migrations for blue-green deployment success. Learn the expand-and-contract pattern to ensure zero-downtime database schema evolution.
Last month, a simple RENAME COLUMN migration brought our production traffic to a crawl because the old application code still expected the original column name. We fixed it with a hotfix, but it reminded me that database schema evolution is the most dangerous part of any deployment.
If you’re running a high-traffic Laravel app, you can't just run php artisan migrate and hope for the best. You need a strategy that keeps your database compatible with two versions of your code simultaneously. That’s where the expand-and-contract pattern comes in.
By default, Laravel migrations are destructive. When you rename a column or drop a table, the current running instance of your application—the "blue" version—often breaks immediately if it relies on that schema. In a blue-green deployment, your database must support both the old code and the new "green" code during the transition period.
We initially tried wrapping everything in a single migration file. It failed because our CI/CD pipeline ran the migration before the new code was fully active. We needed a way to decouple the database change from the application logic.
The core idea is to never perform a breaking change in a single step. Instead, we break the migration into three distinct phases: Expand, Migrate, and Contract.
Instead of renaming a column, you add a new one. Your database now supports both the old code (using the old column) and the new code (using the new column).
PHPSchema::table('users', function (Blueprint $table) { $table->string('full_name')->nullable(); #6A9955">// New column });
Once the new column exists, you need to sync the data. I usually use a background job or a dedicated command for this. Running heavy data migrations inside a standard migration file is a recipe for timeouts, especially if you have millions of rows.
PHP#6A9955">// Run this via a separate Artisan command User::chunk(1000, function ($users) { foreach ($users as $user) { $user->update(['full_name' => $user->first_name . ' ' . $user->last_name]); } });
Only after the new version is deployed and verified do you remove the old column. This is the "contract" step. It’s the safest way to finalize your database schema evolution.
To keep your migrations deterministic, avoid relying on the state of the database. Hard-code your expectations. If you are adding a column, ensure it’s nullable or has a default value so existing code doesn't crash when it attempts to insert records.
I’ve found that using DB::statement for complex changes is sometimes cleaner than the Schema builder. For instance, creating a view to bridge the gap between old and new columns can keep your app running smoothly while you transition.
If you’re managing complex multi-tenant environments, this pattern becomes even more critical. You might want to look at how to handle WordPress plugin architecture for zero-downtime database migrations as a reference for how to manage these transitions across different plugin versions.
Blue-green deployments allow you to switch traffic instantly. However, if your database schema isn't backward compatible, the "switch" is just a trigger for a production incident. By ensuring that your Laravel migrations are always additive during the transition, you remove the "big bang" risk.
Here is a quick checklist for your next deployment:
Honestly, I’m still refining how we handle data synchronization. Running a manual command to sync columns is fine for a few thousand rows, but for our larger tables, we’ve started using database triggers for real-time synchronization. It’s more complex to set up, but it ensures that the "expand" phase never lags behind the application state.
Also, don't forget that if you are using Implementing Laravel Multi-Tenancy with PostgreSQL Schemas, you have to repeat these steps across every tenant schema. Automation is not optional at that scale.
Are you worried about migration locks? Use DB::statement('SET lock_timeout = "5s"') at the start of your migrations. It’s better to fail the migration than to lock your entire production users table for twenty minutes.
Q: Should I use Schema::rename() in production?
A: Never. It locks the table for the duration of the rename. Use the expand-and-contract pattern instead.
Q: How do I handle large datasets during the sync phase?
A: Use chunking in your jobs and add a small usleep() between chunks to keep the database replication lag under control.
Q: Is this overkill for small projects? A: Probably. If you have a maintenance window or low traffic, standard migrations are fine. But if you’re aiming for 99.99% uptime, this is the cost of doing business.
Managing database changes is an exercise in patience. Don't rush the contract phase—leave the old column around for at least one full deployment cycle. You’ll thank yourself when you need to roll back.
Master Laravel read-write splitting with deterministic connection routing. Scale your database performance and high availability without complex external proxies.