
September 08, 2026
13 min read
By Kokil Thapa | Last reviewed: September 2026
Database Migrations at Scale Zero Downtime fail when teams treat schema changes like local dev work. A single blocking ALTER TABLE on a busy orders table can lock writes for minutes. That stalls checkout, booking, and payment callbacks. On production Laravel apps I maintain, schema work is planned like a release. You pair Laravel zero-downtime migration patterns with deploy order, feature flags, and rollback paths. This guide covers what actually works at scale in 2026.
What makes Database Migrations at Scale Zero Downtime different from local migrations?
Local migrations run against empty or tiny datasets. Production tables may hold millions of rows. MySQL 9.7 and PostgreSQL 18 handle DDL differently. A rename that takes 200 ms locally can lock a hot index for 20 minutes under load.
Zero downtime means two things. Users keep using the app. No data is lost if you roll back the application layer. Schema and code must move in compatible steps across multiple deploys.
I treat every production migration as a three-layer change: database schema, application code, and background jobs. Skip any layer and you get 500 errors, silent data drift, or stuck queues. That pattern shows up on booking platforms with live reservation data and on eCommerce carts where order state must stay consistent.
Blocking vs non-blocking operations
Some DDL operations rebuild the entire table. Others add metadata only. Know which category your change falls into before you schedule a deploy window.
| Operation | MySQL 9.7 | PostgreSQL 18 | Zero-downtime safe? |
|---|---|---|---|
| Add nullable column | Instant (InnoDB) | Fast metadata change | Yes — expand phase |
| Add NOT NULL + default | May rebuild table | Can rewrite table | Only with backfill plan |
| Rename column | Blocking rebuild | Blocking in many cases | No — use add-copy-drop |
| Add index (online) | ALGORITHM=INPLACE | CREATE INDEX CONCURRENTLY | Yes — monitor lock wait |
| Drop column | Instant metadata (8.0+) | Fast drop | Yes — contract phase only |
| Change column type | Often full rebuild | Often full rewrite | No — new column + backfill |
When in doubt, assume blocking. Plan expand-contract even if docs say the operation is instant. Replication lag and long transactions can still stall your app.
How do you run Laravel migrations without downtime in production?
Laravel 13.x runs migrations through Artisan. The default php artisan migrate --force inside a deploy hook is fine for additive changes. It is dangerous for destructive ones. I split migration files by risk class and run them at different pipeline stages.
On Deployer 7 pipelines I maintain, the sequence looks like this:
- Deploy new code with backward-compatible schema (expand migrations only).
- Run expand migrations before switching the symlink.
- Reload PHP-FPM so opcache picks up new code.
- Run backfill jobs via queue workers.
- Schedule contract migrations in a later release after verification.
This mirrors the workflow described in zero-downtime Laravel deployment with Deployer. Migration timing matters as much as symlink swaps.
Example: renaming a column safely
Never use $table->renameColumn() on a live table with traffic. Use four migrations across two or three releases instead.
/* Migration 1 — expand: add new column */
Schema::table('orders', function (Blueprint $table) {
$table->string('customer_email')->nullable()->after('email');
});
/* Migration 2 — deploy code that writes BOTH email and customer_email */
/* In Order model observer or saving hook: */
public function saving(Order $order): void
{
if ($order->isDirty('email')) {
$order->customer_email = $order->email;
}
}
/* Migration 3 — backfill command (chunked) */
Order::whereNull('customer_email')
->whereNotNull('email')
->chunkById(500, function ($orders) {
foreach ($orders as $order) {
$order->update(['customer_email' => $order->email]);
}
});
/* Migration 4 — contract: drop old column (later release) */
Schema::table('orders', function (Blueprint $table) {
$table->dropColumn('email');
}); Chunk size depends on row width and index count. Start at 500 rows. Watch InnoDB history list length and replication delay. Increase slowly if the job finishes fast with low CPU.
For larger teams, coordinate through migration workflows in team environments so two developers do not ship conflicting expand and contract steps in the same sprint.
Which MySQL and PostgreSQL settings matter for online DDL at scale?
Engine choice and lock settings decide whether your migration runs in seconds or stalls checkout. Tune these before your first large-table change.
MySQL 9.7 online DDL
Always inspect the migration plan before running it on production. MySQL exposes this through ALGORITHM and LOCK hints.
ALTER TABLE orders
ADD INDEX idx_status_created (status, created_at),
ALGORITHM=INPLACE, LOCK=NONE; Run a dry check first:
ALTER TABLE orders
ADD INDEX idx_status_created (status, created_at),
ALGORITHM=INPLACE, LOCK=NONE,
ALGORITHM=INPLACE; Review the output. If MySQL reports COPY instead of INPLACE, stop. Redesign the migration. The official MySQL InnoDB online DDL documentation lists which operations support inplace algorithms.
On managed hosting or VPS servers, also watch disk I/O during index builds. A secondary index on a 50 GB table can saturate IOPS and slow every query. Schedule heavy index builds off-peak if your provider lacks burst capacity.
PostgreSQL 18 concurrent indexes
Standard CREATE INDEX takes a share lock that blocks writes. Use concurrent mode for production tables with live traffic.
CREATE INDEX CONCURRENTLY idx_orders_status
ON orders (status)
WHERE deleted_at IS NULL; Concurrent index builds cannot run inside a Laravel migration transaction. Use DB::unprepared() or a raw statement migration. Failed concurrent builds leave an invalid index. Drop it and retry. See the PostgreSQL CREATE INDEX docs for failure recovery steps.
How do you backfill millions of rows without overloading the database?
Backfill is where zero-downtime migrations succeed or fail. A naive UPDATE orders SET … on 10 million rows locks pages and fills redo logs. Use chunked queue jobs with throttling instead.
Chunked job pattern in Laravel
class BackfillCustomerEmail implements ShouldQueue
{
use Queueable;
public function __construct(public int $startId, public int $endId) {}
public function handle(): void
{
Order::whereBetween('id', [$this->startId, $this->endId])
->whereNull('customer_email')
->update(['customer_email' => DB::raw('email')]);
}
} Dispatch jobs in ID ranges. Limit concurrent workers during peak hours. I often cap backfill workers at two during business hours and raise to eight overnight. That protects p95 latency on read-heavy pages like product listings and lawyer directory search.
Track progress in a dedicated table or Redis key. Operators need a dashboard answer to "how far along is the backfill?" without SSH access. A simple JSON progress payload logged to your monitoring stack is enough for small teams.
Verification before contract
Never drop the old column until counts match. Run checks like these:
SELECT COUNT(*) FROM orders WHERE email IS NOT NULL AND customer_email IS NULLmust return zero.- Sample 1,000 random rows and compare old vs new values.
- Compare aggregate checksums if columns feed financial reports.
Automate these checks in CI or a post-deploy script. Manual spot checks miss edge cases. I have seen NULL defaults silently corrupt invoice totals on a legal-tech portal until a month-end report caught it.
Before any large backfill, confirm your backup restore process works. Database restore testing you should actually do is not optional when contract migrations drop columns that held billing data.
What rollback and monitoring steps prevent a migration from becoming an outage?
Zero-downtime migration planning includes a rollback story for every phase. If expand migration succeeds but new code crashes, you revert the symlink. Old code must still work against the expanded schema. That means new columns stay nullable and unused until contract.
Pre-migration checklist
- Take a verified backup or snapshot. Confirm restore works on staging.
- Run migration against a staging copy with production-scale row counts.
- Measure migration duration and lock wait under simulated load.
- Document expected row counts before and after backfill.
- Alert on replication lag, lock wait timeout, and queue depth.
- Prepare a kill switch feature flag for code reading the new column.
Automated backups through packages like those covered in Laravel Spatie automated database backups give you a safety net. They do not replace staging rehearsal.
Monitoring during migration
Watch these metrics during expand and backfill phases:
- Lock wait time — spikes mean DDL is blocking writers.
- Replication lag — critical if you use read replicas for Laravel.
- Queue latency — backfill jobs starving user-facing jobs is a common mistake.
- Error rate — 500s after deploy often mean code/schema mismatch.
On Ubuntu servers I administer, I also watch disk space during index builds. Temp tables and sort buffers can consume tens of gigabytes. Low disk triggers MySQL crashes that look like application bugs. Linux server administration and migration planning belong in the same conversation.
Long-running migrations interact with Laravel database transactions and deadlocks. Keep backfill updates short. Avoid wrapping large batches in explicit transactions that hold row locks across seconds.
Index additions at scale
Missing indexes hurt query performance. Adding them wrong hurts availability. Follow database indexing for performance guidelines, but add indexes in their own deploy when tables exceed a few million rows.
On a client booking system, we added a composite index on (status, tour_date) during an off-peak window. We used online DDL on MySQL 8.4 LTS hosting with LOCK=NONE. Query time on the admin dashboard dropped from 4 seconds to 40 ms. The migration itself took 12 minutes but did not block writes.
Schema design mistakes that complicate later migrations
Some zero-downtime pain is self-inflicted at design time. Polymorphic columns without indexes, oversized JSON blobs queried in WHERE clauses, and enum columns that change weekly all make expand-contract harder. Review common database schema design mistakes before your tables reach scale.
For enterprise systems with strict uptime SLAs, factor migration strategy into architecture reviews. Enterprise application development should include a migration playbook, not just ER diagrams.
How do read replicas, connection pooling, and multi-service setups change the plan?
Once you add read replicas or split services, migration compatibility spans more than one codebase. A column drop in the monolith can break a reporting microservice that still SELECTs the old name.
Read replicas apply DDL from the primary. Large index builds on the primary create replication lag. Laggy replicas serve stale data. User profiles, order totals, and inventory counts look wrong until lag clears. Pause non-critical reads or route reporting queries to the primary during heavy DDL if lag exceeds your SLA.
Database connection pooling does not fix lock contention. It can amplify it if hundreds of pooled connections hit a table mid-migration. Reduce pool size temporarily during contract phases if you see lock wait timeouts spike.
Teams moving toward service splits should read monolith to microservices migration strategy for Laravel. Schema ownership per service means contract migrations need cross-team sign-off.
On legal-tech portals like Mijar Law Associates, document tables and payment records cannot afford downtime during schema changes. Dual-write windows and verified backfills are standard practice, not nice-to-haves.
Symfony and Doctrine projects follow the same expand-contract rules. See Symfony migrations with Doctrine best practices for PHP 8.5 and Symfony 8.1 contexts where migration classes differ but the deploy order stays identical.
After contract migrations ship, schedule ongoing support. Support and maintenance retainers should include quarterly migration reviews so technical debt does not accumulate into the next emergency rebuild.
For broader seeding and testing context, see database migrations and seeding best practices in Laravel. Staging data volume should mirror production cardinality even if row content is anonymised.
Key Takeaways
- Never rename, retype, or drop live columns in a single deploy—use expand-contract across two or more releases.
- Run expand migrations before the symlink swap; schedule contract migrations only after backfill verification passes.
- Backfill in chunked queue jobs with throttling; never run one massive UPDATE on millions of rows during peak traffic.
- Use MySQL
ALGORITHM=INPLACE, LOCK=NONEand PostgreSQLCREATE INDEX CONCURRENTLYfor online index builds. - Keep expanded columns nullable so symlink rollback to old code still works without schema restore.
- Monitor lock wait, replication lag, and queue depth during every production migration window.
People Also Ask
Can Laravel migrations run automatically during zero-downtime deploys?
Yes, but only for expand-phase migrations that add nullable columns or online indexes. Destructive migrations should run in a separate pipeline stage with explicit approval. Automate expand steps in Deployer or GitLab CI. Keep contract migrations manual until backfill metrics pass.
How long should you wait between expand and contract migrations?
Wait until backfill reaches 100% and runs clean for at least one full business cycle. For weekly billing systems, that means seven days minimum. Rushing contract drops the old column while straggler rows or async jobs still reference it.
Does zero-downtime migration work on shared hosting?
Partially. Shared hosts often restrict long-running DDL and lack replica control. Additive migrations usually work. Large index builds and backfills may hit CPU or time limits. VPS or dedicated servers with MySQL 8.4 LTS or MySQL 9.7 give you the control zero-downtime work requires.
What is the biggest mistake teams make with production migrations?
Shipping code and schema changes in one atomic deploy without a backward-compatible window. The app expects a new column that does not exist yet, or old code crashes against a dropped column. Expand-contract exists specifically to prevent that mismatch.
Ship schema changes without taking the site offline
Database Migrations at Scale Zero Downtime are a discipline, not a Composer flag. Plan expand and contract phases, chunk your backfills, verify before you drop, and treat every blocking DDL as a production incident waiting to happen. The patterns here work on Laravel 13.x with PHP 8.3+, MySQL 9.7, and PostgreSQL 18—the stacks I deploy and maintain on client projects in Nepal and abroad.
If your application has outgrown casual php artisan migrate runs on live traffic, contact us for a migration audit. We can map your next schema change to a safe multi-release plan before it blocks your users.
Frequently Asked Questions
0 Comments
Leave a comment
Your email is not published. Comments appear once they have been read. Sign in to have your details filled in.

