The Deployment Crash
In the early stages of a B2B SaaS platform at Smart Tech Devs, deploying a database schema change is simple: you put the app in maintenance mode, run php artisan migrate, deploy the new code, and bring the app back online. But as you scale to thousands of active enterprise users, a 2-minute maintenance window becomes unacceptable. You must deploy with zero downtime.
The architectural trap occurs when you try to rename or drop a database column while the app is live. If you rename first_name to full_name, there is a physical fraction of a second where the database has the new column name, but your server is still running the old PHP code. During that window, any user attempting to register will trigger a fatal 500 SQL error because the old code is trying to insert into a column that no longer exists. To deploy safely, you must decouple database changes from code changes using the Expand and Contract Pattern.
The Solution: The Expand and Contract Pattern
Instead of mutating a column in a single destructive step, we stretch the migration across multiple independent, safe deployment phases.
Phase 1: Expand (Add without deleting)
First, we create a migration that adds the new full_name column, but we DO NOT drop or rename the old first_name column. We also update our Laravel Model to write data to both columns simultaneously.
// 1. The Migration
Schema::table('users', function (Blueprint $table) {
$table->string('full_name')->nullable(); // Add the new column safely
});
// 2. The Model Update
class User extends Model
{
// Write to both columns during the transition period to keep data in sync
protected static function booted()
{
static::saving(function ($user) {
if ($user->isDirty('first_name') || $user->isDirty('last_name')) {
$user->full_name = $user->first_name . ' ' . $user->last_name;
}
});
}
}
Deploy this. The application remains perfectly online. New users now have data in both columns.
Phase 2: Migrate (Backfill old data)
Now that our application is writing to both columns, we need to backfill the old historical records. We dispatch a background Queue Job or run an Artisan command to quietly update the old rows without locking the table.
// Run in the background via queues or Laravel Prompts
User::whereNull('full_name')->chunkById(500, function ($users) {
foreach ($users as $user) {
$user->update(['full_name' => $user->first_name . ' ' . $user->last_name]);
}
});
Phase 3: Contract (Cleanup)
Days or weeks later, once you have confirmed the new full_name logic is working flawlessly across your entire dashboard, you write a final migration to safely drop the old columns. You also remove the duplicate writing logic from your Eloquent model.
// The Final Cleanup Migration
Schema::table('users', function (Blueprint $table) {
$table->dropColumn(['first_name', 'last_name']);
});
The Engineering ROI
By breaking destructive schema mutations into an Expand, Migrate, and Contract workflow, you completely eliminate deployment downtime. Your database and your application code are never in a conflicting state, allowing you to execute massive architectural refactors seamlessly in the middle of peak traffic hours without dropping a single user request.
Top comments (0)