A migration is easy to review when it only exists in a pull request. You read the up() method, see a new column, and move on.
Production is less forgiving. The application is running while the migration executes. Queue workers may still have old code in memory. A mobile client released three weeks ago may keep sending the old payload. A large table may take long enough to turn an apparently harmless schema change into an outage.
The mistake is treating a database change as one deployable unit. It is usually two versions of the application and one shared database living together for a while.
Laravel gives us the mechanics: migrations, status checks, dry-run SQL, and isolated execution. The safety comes from the release sequence around them. The Laravel migration documentation describes the commands; this is the checklist I use before I run one against a live system.
The rule: deploy for coexistence
The safest schema change is one that old and new application code can both tolerate.
Suppose an orders.status column has become too vague. The product now needs a more precise fulfilment state. Replacing the old column in a single release sounds tidy:
- Rename
status. - Add
fulfillment_status. - Deploy code that reads the new field.
- Delete the old field when it looks quiet.
That sequence fails the moment an old queue worker wakes up and writes status, or a rollback restores code that still expects it. The schema has moved ahead of the software.
I use a less dramatic sequence instead:
1. Expand Add the new structure without breaking the old one.
2. Backfill Populate it in controlled batches.
3. Switch Make new code read and write the new structure.
4. Verify Check the data, workers, and consumers in production.
5. Contract Remove the old structure in a later release.
It takes more than one deploy. That is the point. The database is the one component every deployed version shares, so it deserves a compatibility window.
1. Expand with an additive migration
The first migration should make the schema more capable, not immediately make it stricter. A nullable column is often the right start because older code does not have to know it exists yet.
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): void {
$table->string('fulfillment_status')->nullable();
$table->index('fulfillment_status');
});
}
public function down(): void
{
Schema::table('orders', function (Blueprint $table): void {
$table->dropIndex(['fulfillment_status']);
$table->dropColumn('fulfillment_status');
});
}
};
This is intentionally boring. It does not rename a column, rewrite every row, or add a non-null constraint that turns incomplete historical data into a deployment blocker.
Adding an index deserves its own question: how large is the table, what database engine is in use, and what kind of lock does that engine take? The schema builder keeps migration code portable. It does not change the operational cost of a large index build. Check the database-specific behaviour before choosing a release window.
2. Backfill outside the migration
I avoid turning a schema migration into a data-processing job. A migration should finish predictably and leave a clear entry in Laravel’s migration table. It is a poor place to load millions of rows, call application services, or fire model events.
Use a command or queued job for the backfill instead. Make it resumable, idempotent, and small enough that it can be monitored.
use App\Models\Order;
use Illuminate\Database\Eloquent\Collection;
Order::query()
->whereNull('fulfillment_status')
->orderBy('id')
->chunkById(500, function (Collection $orders): void {
foreach ($orders as $order) {
$order->update([
'fulfillment_status' => match ($order->status) {
'paid' => 'awaiting_fulfillment',
'shipped' => 'fulfilled',
default => 'pending',
},
]);
}
});
The mapping is product logic. Treat it like product logic: write a focused test for it, decide how unexpected old values behave, and record counts before and after the run. If a batch fails halfway through, the next run should skip rows that already have the new value.
For large tables, I also watch the effect on replication lag, lock waits, and queue throughput. The correct batch size is the one the production database can absorb, not the one that finishes fastest on a laptop.
3. Switch reads and writes deliberately
Once the new field exists, the next application release can write both fields while it is still safe to do so.
Old code: writes status
Compatibility release: writes status and fulfillment_status
New code: reads fulfillment_status, falls back to status when needed
This overlap is especially important for queued work. A worker deployed before the migration may process a job after the migration. A worker deployed after the migration may process a payload created by the old application. The database change is only one half of the compatibility problem.
Make the compatibility rule visible in the code review. If a release changes a database field, ask these questions:
- Can old web processes keep serving requests after this migration runs?
- Can old workers complete jobs created before the deploy?
- Does a retry use a payload shape that the new code still understands?
- Does a rollback require an already-removed column or constraint?
If the answer to any of them is “no”, split the release before it becomes a production incident.
4. Run the migration as a deployment step, not a local ritual
Before the release, I want three pieces of evidence:
php artisan migrate:status
php artisan migrate --pretend
php artisan migrate --force --isolated
migrate:status tells you what Laravel believes is pending. --pretend shows the SQL without executing it, which is useful for catching an unexpected table name or index operation. --force makes an intentional production deployment possible in a non-interactive pipeline.
--isolated matters when more than one application server can run the release command. Laravel acquires an atomic lock so another server does not apply the same migration at the same time. That lock only protects the deployment if every server uses a shared cache store. A file cache on separate hosts is not a coordination mechanism.
I also separate the migration actor from the fleet. One controlled release step runs migrations. Application servers and workers then receive the compatible code. A dozen containers all attempting the schema change is not redundancy; it is a race.
5. Verify before you contract
The old column stays until the new path has earned trust. “The deploy succeeded” is not enough evidence.
I check:
- The number of rows with a null new value is falling or is already zero.
- New records have the new value populated.
- Queue retries and scheduled work do not reference the retired field.
- Dashboards, exports, and administrative tools use the new meaning.
- The rollback plan still works without hiding data loss.
Only then do I schedule a separate contract migration that removes the old column, obsolete index, or temporary fallback. This release should be quiet. If it feels risky, the compatibility phase was too short.
A rollback is not always a reverse migration
Laravel correctly gives migrations down() methods, but a production rollback is not always safe just because one exists. Dropping a new column after the application has started writing data into it destroys information. Rolling back a renamed type may put values into a representation the old code cannot understand.
For a live system, the safer rollback plan is often a forward fix:
- Keep the old and new schema available.
- Restore the previous application version if necessary.
- Preserve the newly written data.
- Correct the migration or application code in a follow-up release.
That is less satisfying than pressing a rollback button. It is also more honest about what production data means.
The release checklist
Before approving a migration for a live Laravel application, I want a clear answer to each item:
[ ] Is the first schema change additive and compatible with old code?
[ ] Is a large data change outside the migration and safe to resume?
[ ] Can web processes, workers, retries, and scheduled jobs coexist?
[ ] Has the generated SQL been reviewed for this database engine?
[ ] Will exactly one release step run migrations in production?
[ ] Is the migration lock backed by a shared cache store?
[ ] Is there evidence that the new path has been populated and exercised?
[ ] Is destructive cleanup scheduled as a later, separate release?
The safest migrations do not look clever. They look slower than a one-shot schema rewrite because they make room for old code, live data, and the awkward reality of rollback. That is the cost of changing a shared production dependency without asking every running version of the application to disappear first.