Every PHP developer has a story about the migration that took the site down. A dropColumn that ran before the new code was deployed. An ALTER TABLE on a 40 million row table that locked writes for eleven minutes. A backfill loop inside a migration that exhausted memory halfway through and left the data in a half-updated state. It is argued more carefully on laracasts.com.
Running a Laravel database migration in production is not the same exercise as running one on your laptop. Locally you can always migrate:fresh. In production, that command is a career event. This guide is the repeatable process our team at Box Software uses on live applications: how to write reversible schema changes, how to backfill columns in batches without blocking the database, and how to deploy structural changes with zero downtime.
Why Laravel Migrations Break Production (and Why It Is Rarely Laravel’s Fault)
Laravel migrations are just a thin, versioned wrapper around DDL statements. The framework runs what you tell it to run. Production incidents almost always come from a small, predictable set of causes:
- Destructive changes shipped too early. The column is dropped while the old code, still running on half the servers, keeps selecting it.
- Locking DDL on large tables. Some
ALTER TABLEoperations rewrite the whole table and block writes for the duration. - Data manipulation inside schema migrations. A loop over 2 million rows inside
up()that times out, cannot resume and cannot be rolled back. - Missing or fake
down()methods. The rollback path was never written, never tested, or silently does nothing. - Schema drift. Someone applied a manual hotfix directly on the production database, so the migration that works on staging fails on production.
- No backup checkpoint. The last verified restore was months ago and nobody actually knows if it works.
Fix those six and production migrations become boring, which is exactly what you want.

Step 1: The Pre-Flight Checklist
Before any migration touches a live database, run through this list. It takes five minutes and prevents most incidents.
| Check | Command or action |
|---|---|
| Fresh, verified backup | Take a snapshot and confirm it restores on a scratch instance |
| Know what will run | php artisan migrate --pretend |
| Know the pending list | php artisan migrate:status |
| Know the row counts | php artisan db:table users and php artisan db:show |
| Detect schema drift | Diff production schema against your committed schema/*.sql dump |
| Rollback plan written | Tested down() or a documented forward fix |
| Deploy order agreed | Migration before code, or code before migration (see Step 3) |
Use –pretend as a Code Review Tool
The single most underused flag in the Artisan toolbox converts every pending migration into raw SQL, prints it and stops without touching the database:
php artisan migrate --pretend
Run it against a copy of production (or production itself, it is read-only in intent) and paste the output into the pull request. Reviewers judge the actual SQL, not your intention. If a statement looks like a full table rewrite, you will see it here. Related reading: Leveraging Laravel Migrations for Cloud Deployments.
Step 2: Write Genuinely Reversible Migrations
A migration without a working down() is a one-way door. Laravel can guess reversals for simple cases, but you should write them explicitly.
<?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('invoices', function (Blueprint $table) {
$table->string('reference', 32)->nullable()->after('id');
$table->index('reference', 'invoices_reference_index');
});
}
public function down(): void
{
Schema::table('invoices', function (Blueprint $table) {
$table->dropIndex('invoices_reference_index');
$table->dropColumn('reference');
});
}
};
Rules for Reversibility
- Always name your indexes and foreign keys. Auto-generated names differ between drivers and make
down()fragile. - Never put a destructive change and a creative change in the same migration. One concern per file keeps rollbacks surgical.
- Make the migration idempotent where it is cheap to do so using
Schema::hasColumn(),Schema::hasTable(),Schema::whenTableDoesntHaveColumn(). This saves you when a migration fails halfway and gets re-run. - Accept that some changes are not reversible. If you drop a column with data,
down()can recreate the column but not the values. Say so in a comment, and rely on the backup instead.
public function up(): void
{
Schema::whenTableDoesntHaveColumn('orders', 'currency', function (Blueprint $table) {
$table->char('currency', 3)->default('EUR');
});
}
Control Transaction Behaviour
PostgreSQL supports transactional DDL, so a failed migration rolls back cleanly. MySQL does not: a failed multi-statement migration leaves you in a half-applied state. For operations that must not run inside a transaction (such as CREATE INDEX CONCURRENTLY on PostgreSQL), disable it on the migration class:
return new class extends Migration
{
public $withinTransaction = false;
public function up(): void
{
DB::statement('CREATE INDEX CONCURRENTLY idx_orders_customer_id ON orders (customer_id)');
}
public function down(): void
{
DB::statement('DROP INDEX CONCURRENTLY IF EXISTS idx_orders_customer_id');
}
};

Step 3: The Expand and Contract Pattern
This is the core technique for zero-downtime schema changes. Instead of one deploy that changes the schema and the code simultaneously, you split the work into phases where old code and new schema are always compatible.
Take the classic example: renaming users.name to users.full_name. A direct rename breaks every running process the moment it executes. Here is the safe sequence.
| Phase | Database | Application code |
|---|---|---|
| 1. Expand | Add nullable full_name |
Unchanged, still reads name |
| 2. Dual write | No change | Writes both columns, reads name |
| 3. Backfill | Batched copy of old values | Unchanged |
| 4. Switch reads | No change | Reads full_name, still writes both |
| 5. Stop dual write | No change | Only full_name |
| 6. Contract | Drop name |
Unchanged |
Yes, that is up to four deploys for one rename. It is also the reason nobody gets paged at 2am. Run phase 6 days or weeks later, once you are certain no queued job, scheduled command or cached view still references the old column.
Practical Expand and Contract Rules
- New columns are always nullable or have a default in the expand phase. A
NOT NULLcolumn with no default on a populated table will fail or lock. - Never drop or rename in the same release as the code change. Deploys are not atomic, and rolling deployments run two versions of your code at once.
- Add constraints last. Backfill the data first, then promote the column to
NOT NULLor add the unique index. - Queued jobs are old code. A job serialized before the deploy may be executed after it. Treat your queue as a second, lagging application version.
Step 4: Backfill Data in Batches, Outside the Migration
This is the rule that saves the most incidents: schema migrations change structure, Artisan commands change data. Putting a mass update inside up() gives you no progress reporting, no resumability, no rate limiting and no safe rollback.
Why a Command Beats a Migration for Data
- You can run it manually, at a chosen hour, with monitoring open.
- You can stop it, fix the logic and resume from the last processed ID.
- You can throttle it so replication lag stays under control.
- Your deploy pipeline is not held hostage by a 40 minute data operation.
A Resumable Batched Backfill Command
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
class BackfillInvoiceReferences extends Command
{
protected $signature = 'backfill:invoice-references
{--chunk=1000 : Rows per batch}
{--sleep=200 : Milliseconds to pause between batches}
{--from-id=0 : Resume from this primary key}';
protected $description = 'Backfill invoices.reference in safe batches';
public function handle(): int
{
$chunk = (int) $this->option('chunk');
$sleep = (int) $this->option('sleep');
$lastId = (int) $this->option('from-id');
$total = 0;
do {
$rows = DB::table('invoices')
->select('id', 'created_at')
->whereNull('reference')
->where('id', '>', $lastId)
->orderBy('id')
->limit($chunk)
->get();
if ($rows->isEmpty()) {
break;
}
DB::transaction(function () use ($rows) {
foreach ($rows as $row) {
DB::table('invoices')
->where('id', $row->id)
->update([
'reference' => sprintf('INV-%s-%06d', date('Y', strtotime($row->created_at)), $row->id),
]);
}
});
$lastId = $rows->last()->id;
$total += $rows->count();
$this->info("Processed {$total} rows, last id {$lastId}");
usleep($sleep * 1000);
} while (true);
$this->info("Backfill complete. {$total} rows updated.");
return self::SUCCESS;
}
}
Key details that make this production safe:
- Keyset pagination (
where id > lastId) instead ofoffset, so performance stays constant on huge tables. - The filter is the work itself (
whereNull('reference')), which makes the command naturally idempotent and restartable. - A deliberate pause between batches gives replicas time to catch up and leaves IO for real traffic.
- Small transactions per batch instead of one giant transaction holding locks for minutes.
If your transformation is expensive (API calls, image processing, re-encryption), dispatch a queued job per batch instead and let Horizon handle concurrency and retries.
When the Set-Based Update Is Better
If the value can be computed entirely in SQL, a batched single statement is far faster than row-by-row PHP:
DB::statement('
UPDATE orders
SET total_cents = ROUND(total * 100)
WHERE total_cents IS NULL
ORDER BY id
LIMIT 5000
');
Loop that statement until the affected row count is zero, with a short sleep between iterations.
Step 5: Know Which Operations Lock Your Table
Not every ALTER TABLE is dangerous, and the answer depends on your engine. Use this as a rough map, then verify on a production-sized copy.
| Operation | MySQL 8 / InnoDB | PostgreSQL 14+ |
|---|---|---|
| Add nullable column | Instant in most cases | Instant (metadata only) |
| Add column with default | Instant (8.0.12+) | Instant (11+) |
| Add index | Online, writes allowed, but heavy IO | Blocks writes unless CONCURRENTLY |
| Change column type | Usually full table rebuild | Usually full table rewrite with ACCESS EXCLUSIVE lock |
| Rename column | Fast, but breaks running code | Fast, but breaks running code |
| Drop column | Instant drop (8.0.29+) or rebuild | Instant (metadata only) |
| Add foreign key | Validates existing rows, can be slow | Use NOT VALID then VALIDATE CONSTRAINT |
Two Protective Habits
- Set a lock timeout so a migration fails fast instead of queueing every other query behind it. On PostgreSQL:
SET lock_timeout = '5s';before the DDL. On MySQL:SET SESSION lock_wait_timeout = 10;. - Use an online schema change tool for very large MySQL tables, such as
pt-online-schema-changeorgh-ost. Generate the SQL withmigrate --pretend, run it through the tool, then mark the migration as applied.

Step 6: Running the Migration on the Live Database
In a non-interactive production environment Laravel will refuse destructive prompts, so your deploy script needs the right flags.
# Standard production deploy step
php artisan migrate --force
# Multi-server deploy: only one node acquires the lock and runs them
php artisan migrate --force --isolated
# Apply one migration at a time so each gets its own batch for rollback
php artisan migrate --force --step
Flags Worth Knowing
| Flag | What it does |
|---|---|
--force |
Skips the “are you sure” prompt in production. Required in CI/CD. |
--pretend |
Prints the SQL instead of executing it. |
--isolated |
Uses an atomic lock so concurrent deploys do not run migrations twice. |
--step |
Records each migration in its own batch for granular rollback. |
--graceful |
Exits without an error code on failure, useful in some pipelines but hides problems. Use sparingly. |
--path= |
Runs a specific migration directory, handy for multi-tenant databases. |
Order of Operations in the Deploy Script
For additive migrations (the expand phase), migrate before swapping the code, so the new code never meets the old schema:
- Build the new release directory
php artisan migrate --force --isolated- Swap the symlink to the new release
php artisan config:cache,route:cache,event:cache- Reload PHP-FPM and restart queue workers (
php artisan queue:restart) - Run the backfill command manually, when you are watching
For the contract phase (drops), do the opposite: deploy the code first, let it bake, then run the destructive migration in a separate release.
Multi-Tenant Databases
If you run a database per tenant, never loop over 500 tenants in a single synchronous deploy step. Dispatch one queued job per tenant, record success or failure per tenant in a tracking table, and make the job safe to retry. A single failing tenant should not block the release.
Step 7: A Rollback Plan That Actually Works
Reflex says php artisan migrate:rollback. In production, that is often the wrong move: rolling back a destructive migration cannot bring data back, and it may collide with code that is still live.
Decide in advance which of the three strategies applies:
- Roll back the migration when the change is purely additive and no data was written to the new structure yet. Safe and cheap.
- Roll forward with a fix migration when data already lives in the new structure. Write a new migration that corrects the problem. This is the right answer in most cases.
- Restore from backup when data has been lost or corrupted. Slow, painful, and the reason step 1 of the checklist exists.
Document the chosen strategy in the pull request description. During an incident, nobody wants to improvise.
Step 8: Test Migrations Before They Reach Production
A migration is code. Treat it like code.
In Continuous Integration
- Run
php artisan migrate --forceon a clean database, thenphp artisan migrate:rollback --force, then migrate again. If any step fails, the build fails. - Keep a committed schema dump with
php artisan schema:dumpso new environments build fast and you have a reference for drift detection. - Write a test for the backfill command: seed representative rows, run the command, assert the resulting values and assert that running it twice changes nothing.
On a Production Clone
The only reliable way to know how long a migration takes is to run it against production-sized data. Restore last night’s backup to a scratch instance, time the migration, and record the duration in the pull request. A change that takes 4 seconds on 10,000 rows may take 25 minutes on 30 million.
Guard Against Schema Drift
Manual hotfixes on production databases happen. Add a scheduled check that compares the live schema with the schema your migrations produce, and alert when they diverge. Catching drift on a Tuesday afternoon is infinitely better than discovering it during a Friday deploy.

The Repeatable Checklist
Copy this into your pull request template:
- Migration is additive only, or destructive changes are in a separate, later release
down()is written and tested locally- Output of
migrate --pretendis attached to the PR - Estimated duration measured on a production-sized dataset
- No data manipulation inside the migration file
- Backfill lives in a dedicated, batched, resumable Artisan command
- New columns are nullable or defaulted; constraints added after backfill
- Indexes are named explicitly
- Queue workers restarted after deploy
- Backup verified within the last 24 hours
- Rollback strategy chosen: rollback, roll forward or restore
Common Mistakes We Still See in 2026
- Using Eloquent models inside migrations. The model changes over time; the migration must stay frozen. Use the query builder with explicit table names.
- Calling
migrate:freshormigrate:refreshwith production credentials loaded. Restrict these commands in non-local environments. - Editing a migration that has already run in production. It will never re-run there. Ship a new migration instead.
- Seeding business data through seeders in production. Reference data belongs in an explicit, versioned, idempotent data migration or command.
- Forgetting that
dropColumnon older SQLite setups rebuilds the table. Test on the engine you actually deploy to, not only on SQLite in CI.
FAQ
Should I run Laravel migrations automatically on every deploy?
Yes for additive, fast migrations, using php artisan migrate --force --isolated in the pipeline. No for long-running or destructive ones: those should be executed deliberately, by a human, in a monitored window.
How do I preview a Laravel migration before running it in production?
Use php artisan migrate --pretend. Laravel converts every pending migration into raw SQL, prints it to the terminal and executes nothing. Combine it with php artisan migrate:status to confirm exactly which files are pending.
Can I put data updates inside a schema migration?
For a handful of rows, yes. For anything that touches a meaningful portion of a large table, no. Put it in a dedicated Artisan command so it can be batched, throttled, monitored, paused and resumed without blocking your deployment.
How do I add a NOT NULL column to a table that already has millions of rows?
Three steps across separate releases: add the column as nullable (or with a default), backfill it in batches, then alter it to NOT NULL once no null values remain. Never attempt all three in one migration.
What is the safest way to rename a column in production?
Do not rename it directly. Add the new column, dual write from the application, backfill the historical rows in batches, switch reads to the new column, stop writing the old one, and only then drop it in a later release.
Does migrate:rollback restore deleted data?
No. down() only reverses structure. If a column or table was dropped, the data is gone and only a backup can bring it back. That is why destructive migrations always require a verified, recent snapshot. cloudways.com has covered this at length.
How do I handle migrations across many tenant databases?
Dispatch one queued job per tenant rather than looping synchronously during the deploy. Track per-tenant status, make each job retryable and idempotent, and alert on tenants that remain behind the expected migration version.
Final Thoughts
A safe Laravel database migration in production is not about clever code. It is about sequencing: additive first, data second, destructive last, each in its own release, each with a measured duration and a written rollback plan. Once the team follows the same checklist on every pull request, migrations stop being the scary part of a deploy.
If you are running a Laravel application where downtime has a real cost, our team at Box Software can audit your migration workflow, your deploy pipeline and your database schema, then set up the guardrails that keep releases boring. Get in touch to discuss your project.
