Migrations in Production
How a routine migration locks a large table, and the two-step pattern that keeps schema and running code compatible.
php artisan migrate --pretendA migration that takes 40 milliseconds on your laptop can take a site down. The reason is row count and locking: an alter table on a large table may copy every row, and on some changes writes queue behind the copy. The migration finishes eventually; the request timeouts do not wait for it.
Look before you run
php artisan migrate --pretend executes no DDL. It prints each pending migration and the SQL statements up() would issue, which is the only reliable way to see what your Blueprint compiled to for this connection. In production migrate stops and asks for confirmation unless you pass --force, and --isolated takes a lock so two application servers deploying at once do not both migrate.
Change in two steps
Do not rename or narrow a column in a single deploy. While the migration runs, the old code is still serving traffic, and after it the new code expects the new shape. Split it: add the new nullable column, deploy code that writes both, backfill in batches, then drop the old column in a later deploy. Every step leaves the schema usable by the code on either side of it.
No transaction on MySQL
Laravel wraps a migration in a transaction only when the connection's grammar supports transactional DDL, which MySQL does not. A migration that fails on its third statement there leaves the first two applied and writes no row to the migrations table, so the re-run fails on a column that already exists.
Example
<?php
// database/migrations/2026_03_18_091500_add_email_address_to_users_table.php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Step 1 of 3 in renaming users.email to users.email_address.
*
* Deploy A (this file): add the new column, nullable, and ship code that
* writes both columns and reads the old one.
* Deploy B: backfill the new column in batches, then read the new one.
* Deploy C: drop users.email, once nothing in the codebase names it.
*
* Doing all three at once breaks every request served between the DDL
* finishing and the new code going live.
*/
return new class extends Migration
{
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
// after() is MariaDB and MySQL only; it is cosmetic and safe to drop.
$table->string('email_address', 254)->nullable()->after('name');
// On MySQL you can also ask for a specific online-DDL mode by
// chaining a modifier onto the column. MySQL raises an error if
// it cannot honour the request, which is better than a silent
// table copy at 09:00:
// ->lock('none') allows concurrent reads and writes
// ->instant() skips the table rebuild entirely, but can
// only append the column to the end of the
// table, so it cannot be combined with
// after() or first()
});
}
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropColumn('email_address');
});
}
};When to use it
- Add a column to a ten-million-row table during business hours without blocking writes.
- Rename a column across three deploys while the old code keeps serving traffic.
- See the exact SQL a migration will run against production before the deploy starts.
- Stop two application servers from running the same migration at the same moment.
More examples
Backfilling without one long transaction
The backfill is deliberately a separate migration from the DDL, so a timeout halfway through does not leave the column question open. $withinTransaction is a public property on Illuminate\Database\Migrations\Migration and defaults to true; setting it false matters on PostgreSQL and SQL Server, the only schema grammars that report transactional DDL, where Laravel would otherwise wrap the whole loop in one transaction. On MySQL, MariaDB and SQLite there is no wrapping transaction to opt out of. The usleep gives replicas room to catch up.
<?php
// database/migrations/2026_03_25_070000_backfill_users_email_address.php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
/**
* One UPDATE over a million rows holds locks and inflates the undo log
* for as long as it runs. Walk the primary key in pages instead, and
* opt out of the wrapping transaction on connections that have one.
*/
public $withinTransaction = false;
public function up(): void
{
$lastId = 0;
while (true) {
$ids = DB::table('users')
->where('id', '>', $lastId)
->whereNull('email_address')
->orderBy('id')
->limit(1_000)
->pluck('id');
if ($ids->isEmpty()) {
break;
}
DB::table('users')
->whereIn('id', $ids)
->update(['email_address' => DB::raw('email')]);
$lastId = $ids->last();
usleep(50_000);
}
}
public function down(): void
{
// Data copy only. Nothing to reverse.
}
};Reading the SQL before you ship it
Do this on a copy of the production connection, because the SQL depends on the grammar: the same Blueprint produces different DDL on MySQL and PostgreSQL. It is how you catch that a change you thought was an add is compiled as a modify, and how you spot a column whose declared length makes an index larger than you intended.
# No DDL is executed. Each pending migration is printed with the
# statements its up() method would run on this connection.
php artisan migrate --pretend
# 2026_03_18_091500_add_email_address_to_users_table
# alter table `users` add `email_address` varchar(254) null after `name`
# Production run: --force skips the confirmation prompt, --isolated takes
# a lock so only the first server to arrive runs the migrations.
php artisan migrate --force --isolated
# What has run, and what has not.
php artisan migrate:statusA migration that tolerates being re-run
Schema::whenTableDoesntHaveColumn(string $table, string $column, Closure $callback) inspects the live schema and runs the callback only if the column is absent; whenTableHasColumn is the inverse. Worth the extra line on MySQL, where a partly applied migration cannot be rolled back and someone has to re-run it. Schema::hasTable() and Schema::hasColumn() do the same check when you need the boolean yourself.
<?php
// database/migrations/2026_03_18_091500_add_email_address_to_users_table.php
declare(strict_types=1);
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::whenTableDoesntHaveColumn('users', 'email_address', function (Blueprint $table) {
$table->string('email_address', 254)->nullable();
});
}
public function down(): void
{
Schema::whenTableHasColumn('users', 'email_address', function (Blueprint $table) {
$table->dropColumn('email_address');
});
}
};
Discussion