Summary
Most Laravel outages during a release are not caused by bad PHP code. They are caused by a database migration that locks a busy table, rewrites millions of rows, or leaves the schema half-changed while the new code is already serving traffic. On a small app you never notice. On a SaaS product with large tables and users in several time zones, a single php artisan migrate can freeze checkout or login for minutes.
The fix is not a magic package. It is a small set of habits:
- Split risky changes into expand and contract steps so old and new code can both run against the schema.
- Know which operations are instant and which lock or rewrite a table on MySQL and PostgreSQL.
- Set lock timeouts so a migration fails fast instead of queuing every query behind it.
- Move data backfills out of migrations and into chunked, queued jobs.
- Monitor and react when a migration fails, which Laravel 13.42 makes easier with the new
MigrationFailedevent.
This guide walks through each step with Laravel code you can use today, plus a pre-release checklist for CTOs and lead developers.
Why migrations cause downtime
There are three common ways a migration takes a production app down.
1. Locks that block normal queries
Almost every schema change needs a lock on the table. On PostgreSQL, many ALTER TABLE forms take an ACCESS EXCLUSIVE lock, which blocks reads and writes. On MySQL, DDL needs a metadata lock. The lock itself may only be held briefly, but if a long-running query or open transaction is already using the table, the migration waits, and every new query on that table queues up behind the migration. Your app looks frozen even though the DDL has not started yet.
2. Table rewrites on large tables
Some changes force the database to copy the whole table: changing a column type, adding certain defaults on older versions, or adding a constraint that must be checked against every row. On a table with tens of millions of rows this can take a long time, and depending on the operation, writes may be blocked throughout.
3. Code and schema out of step
During a rolling deploy, or between running migrations and restarting PHP-FPM, Octane workers and queue workers, old code and new schema exist at the same time. If you rename or drop a column in one release, old workers still reading the old column start throwing errors. Queued jobs serialised by old code can also fail when they are processed by new code.
There is one more trap on MySQL: DDL is not transactional. If a migration runs three ALTER TABLE statements and the third fails, the first two stay applied. Laravel cannot roll them back for you.
The expand and contract pattern
The core idea of zero-downtime migrations is simple: never make a change that the currently running code cannot handle. Instead of one breaking release, you ship several compatible ones.
- Expand: add the new structure (column, table or index) in a way that is backward compatible. Old code ignores it.
- Migrate code: deploy code that writes to both old and new structures, then backfill existing rows.
- Switch reads: deploy code that reads from the new structure.
- Contract: once nothing uses the old structure, drop it in a later release.
It is more releases, but each one is small, safe and easy to roll back. For example, renaming users.name to users.full_name becomes:
// Release 1: expand - add the new nullable column
Schema::table('users', function (Blueprint $table) {
$table->string('full_name')->nullable();
});
// Release 1 (code): write to both columns
class User extends Model
{
protected static function booted(): void
{
static::saving(function (User $user) {
if ($user->isDirty('name')) {
$user->full_name = $user->name;
}
});
}
}
Then a backfill job copies existing values (see below), release 2 switches reads to full_name, and release 3 drops name. At no point does running code reference a column that does not exist.
Safe vs risky operations on MySQL and PostgreSQL
The rules depend on your database engine and version, so always check the documentation for the exact version you run in production. These are the general patterns for MySQL 8 and modern PostgreSQL.
Adding a column
- PostgreSQL: adding a nullable column, or a column with a constant (non-volatile) default, is a metadata-only change since PostgreSQL 11. It is fast, but still needs a brief exclusive lock, so use a lock timeout. A volatile default such as a random value forces a rewrite.
- MySQL 8: most column additions can use
ALGORITHM=INSTANT. Newer 8.0 releases allow instant adds in any column position, earlier ones only as the last column. If instant is not possible, MySQL falls back to a slower algorithm unless you specify one, so it is worth being explicit.
Adding an index
- PostgreSQL: a plain
CREATE INDEXblocks writes for the entire build. UseCREATE INDEX CONCURRENTLY, which cannot run inside a transaction. - MySQL 8: InnoDB can build secondary indexes online (
ALGORITHM=INPLACE, LOCK=NONE), allowing reads and writes while it builds. It still needs a metadata lock at the start and end.
Adding NOT NULL or foreign key constraints
Both databases need to verify existing rows. On PostgreSQL you can add a CHECK or foreign key constraint as NOT VALID first (fast, only enforced on new rows), then run VALIDATE CONSTRAINT separately, which uses a weaker lock and does not block writes.
Changing a column type, renaming, dropping
Type changes often rewrite the table on both engines. Renames and drops are fast at the database level but break old code. Treat all three as expand and contract changes. For very large MySQL tables where a rewrite is unavoidable, online schema change tools such as gh-ost or pt-online-schema-change copy the table in the background and swap it in at the end.
If you are still choosing a database for a new product, our comparison of PostgreSQL vs MySQL for SaaS covers how these operational differences play out over time.
Writing safer Laravel migrations
Create PostgreSQL indexes concurrently
Laravel wraps each migration in a transaction on databases that support transactional DDL, such as PostgreSQL. Because CREATE INDEX CONCURRENTLY cannot run in a transaction, turn that off for the migration:
return new class extends Migration
{
public $withinTransaction = false;
public function up(): void
{
DB::statement('CREATE INDEX CONCURRENTLY IF NOT EXISTS orders_customer_id_index ON orders (customer_id)');
}
public function down(): void
{
DB::statement('DROP INDEX CONCURRENTLY IF EXISTS orders_customer_id_index');
}
};
If a concurrent build fails, PostgreSQL can leave an invalid index behind. Check for it and drop it before retrying.
Fail fast with lock timeouts
A migration that waits for a lock is worse than one that fails. Set a short timeout so it gives up and you can retry at a quieter moment:
public function up(): void
{
// PostgreSQL: applies to this migration's transaction only
DB::statement("SET LOCAL lock_timeout = '5s'");
Schema::table('orders', function (Blueprint $table) {
$table->string('external_ref')->nullable();
});
}
On MySQL, the equivalent is the session variable lock_wait_timeout, which controls how long DDL waits for a metadata lock:
DB::statement('SET SESSION lock_wait_timeout = 5');
DB::statement('ALTER TABLE orders ADD COLUMN external_ref VARCHAR(255) NULL, ALGORITHM=INSTANT');
Writing raw SQL for the risky parts is fine. The Schema builder is convenient, but it does not let you pick MySQL algorithms or PostgreSQL concurrency options.
One risky change per migration
Because MySQL DDL is not transactional, put each risky statement in its own migration file. If one fails, you know exactly what has and has not been applied, and re-running is predictable.
Preview and isolate
Run php artisan migrate --pretend in CI or staging to see the exact SQL Laravel will execute. In production, php artisan migrate --force --isolated uses an atomic cache lock so that only one server runs migrations when several deploy at once.
Move data backfills out of migrations
A migration that updates millions of rows in one statement holds locks for a long time, bloats the transaction log and can time out your deploy. Run backfills as a queued job that processes small batches:
class BackfillUserFullName implements ShouldQueue
{
use Queueable;
public function handle(): void
{
User::query()
->whereNull('full_name')
->chunkById(1000, function ($users) {
User::whereIn('id', $users->pluck('id'))
->update(['full_name' => DB::raw('name')]);
});
}
}
Small batches keep each update short, let normal traffic interleave with the backfill, and make the job safe to restart because it only touches rows that still need work. On very large tables, add a short pause between batches or run the job during lower-traffic hours, and watch replica lag if you use read replicas.
React when a migration fails
Laravel 13.42 added a MigrationFailed event that fires when a migration throws, carrying the migration and the exception. The exception is still thrown afterwards, so your deploy still fails, but you can alert the team immediately:
use Illuminate\Database\Events\MigrationFailed;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Log;
Event::listen(function (MigrationFailed $event) {
Log::critical('Migration failed', [
'migration' => $event->name,
'error' => $event->exception->getMessage(),
]);
// e.g. notify Slack, or put the app in maintenance mode
});
Whether to put the app into maintenance mode automatically depends on the change. If a half-applied MySQL migration would corrupt data, going down is safer. If it was an additive change that old code ignores, an alert is enough. Pair this with error tracking and database monitoring so you can see lock waits and slow queries while a release is in progress.
Deployment order that works
For most Laravel teams on Forge, Envoyer, Laravel Cloud, Kubernetes or a custom CI pipeline, this order keeps releases safe:
- Build the release and run tests, including
migrate --pretendagainst a production-like schema. - Run additive (expand) migrations while the old code is still live.
- Switch traffic to the new code, then reload PHP-FPM or Octane workers and restart queue workers with
php artisan queue:restart. - Run backfill jobs and verify the data.
- In a later release, after confirming nothing uses the old structure, run the contract migration.
Avoid relying on php artisan down for routine releases. Maintenance mode is a useful emergency brake, but if you need it every deploy, your migrations are doing too much at once. Our cloud and DevOps team often starts client engagements by fixing exactly this kind of deploy pipeline.
Pre-release migration checklist
- Does any running code (web, queue workers, scheduled commands) reference a column or table this release renames or drops?
- Is each risky DDL statement in its own migration?
- Are PostgreSQL indexes created concurrently with
$withinTransaction = false? - Is a lock timeout set for changes on busy tables?
- Have you tested the migration on a copy of production-sized data and timed it?
- Are large data updates in a chunked, queued job rather than the migration?
- Is there an alert on migration failure and a clear rollback plan?
- Do long-running reports or transactions run at the same time as the deploy?
FAQ
Can Laravel run migrations with zero downtime out of the box?
Laravel runs whatever SQL your migrations describe. It does not automatically choose online algorithms or concurrent indexes. Zero downtime comes from how you design and order migrations, using expand and contract, lock timeouts and separate backfills.
Should I use the down() method for rollbacks in production?
It is useful in development, but in production it is usually safer to roll forward with a new migration. Rolling back a drop or a type change can lose data, and on MySQL a failed migration may already be partly applied.
Is adding a column with a default value safe?
On PostgreSQL 11 and later, adding a column with a constant default is a fast metadata change. On MySQL 8, most column additions can be instant. In both cases, use a lock timeout because the change still needs a brief lock.
When do I need gh-ost or pt-online-schema-change?
When a MySQL change on a large, busy table cannot run with ALGORITHM=INSTANT or an online INPLACE operation, for example some column type changes. These tools copy the table in the background and swap it in with minimal locking.
How do I rename a column safely in Laravel?
Add the new column, write to both columns, backfill existing rows in chunks, switch reads to the new column, and drop the old column in a later release once no code uses it.
Can DayDecoder review our migrations before a big release?
Yes. We review migration plans, deploy pipelines and database performance for Laravel teams, and can run the risky changes with your team on release day.
Written by
Prakash Tank
Founder & Full-Stack Architect, DayDecoder
Prakash started as a certified Laravel developer and grew into a full-stack architect who owns a project end to end: requirements, architecture, development, deployment, and support. He designs event-driven systems with...
More about Prakash