
September 09, 2026
12 min read
By Kokil Thapa | Last reviewed: September 2026
A deploy that runs php artisan migrate --force against a live database can lock tables and break requests mid-flight. Zero-downtime Laravel database migrations treat schema changes as a release strategy, not a single SQL file. You ship code and schema in compatible steps so old and new app versions run safely at the same time. This guide covers the patterns I use on production Laravel 12 and 13 apps backed by MySQL 8.4 LTS or PostgreSQL 18, paired with Laravel migration best practices for zero downtime and symlink-based deploys.
What makes a Laravel database migration zero-downtime?
Zero downtime does not mean migrations finish instantly. It means users never see failed requests because of schema drift or table locks. Laravel’s migration runner is simple: it applies pending files in order. Production is not simple. You often have two app versions online during a zero-downtime Deployer release.
The rule is compatibility across versions. Old code must keep working after the migration. New code must work before and after the migration. If either side breaks, you need maintenance mode or you accept errors.
Think in three layers: application code, migration files, and database engine behaviour. Laravel controls the first two. MySQL and PostgreSQL control locking, rebuild time, and replica lag. A migration that is “safe” in SQLite can stall InnoDB on a 40 GB orders table.
The expand-contract pattern
Expand-contract is the core technique for zero-downtime Laravel database migrations. You expand the schema first, migrate behaviour in application code, then contract unused structures later.
- Expand: Add the new column, index, or table. Keep it nullable or unused.
- Dual-write / dual-read: Ship code that writes to both old and new fields when needed.
- Backfill: Copy or transform existing rows with a queued job or Artisan command.
- Switch reads: Deploy code that reads the new field as the source of truth.
- Contract: Drop the old column, index, or table in a later release.
Each step is its own deploy. That feels slow. It prevents the classic outage: migration renames name to full_name while v1 still selects name.
How do you write expand-contract migrations in Laravel?
Laravel 13.x migrations use the same Schema builder as Laravel 12. The difference is discipline, not syntax. Never use ->renameColumn() on a hot table in one step. Add the new column, backfill, switch code, then drop the old column across separate releases.
Example: rename users.email to users.primary_email without downtime.
/* database/migrations/2026_09_01_000001_add_primary_email_to_users.php */
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
$table->string('primary_email')->nullable()->after('email');
$table->index('primary_email');
});
}
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropIndex(['primary_email']);
$table->dropColumn('primary_email');
});
}
Release 1 runs that migration. v1 code still reads email. Release 2 ships an observer or model mutator that mirrors writes:
/* app/Observers/UserObserver.php */
public function saving(User $user): void
{
if ($user->isDirty('email')) {
$user->primary_email = $user->email;
}
if ($user->isDirty('primary_email')) {
$user->email = $user->primary_email;
}
}
Release 3 dispatches a backfill job in chunks:
User::whereNull('primary_email')
->whereNotNull('email')
->orderBy('id')
->chunkById(500, function ($users) {
foreach ($users as $user) {
$user->forceFill(['primary_email' => $user->email])->saveQuietly();
}
});
Release 4 changes reads to primary_email. Release 5 drops email. That is five deploys for one rename. It is also five deploys without a login outage on a legal-tech portal with steady traffic.
For deeper team workflow notes, see database migrations in team environments and migration and seeding best practices in Laravel.
Which Laravel schema changes are safe in one deploy?
Not every migration needs five releases. Some changes are cheap if the table is small or the engine supports online DDL. The mistake is assuming all Schema:: calls behave the same on production row counts.
| Change | Usually safe online | Needs expand-contract | Notes |
|---|---|---|---|
| Add nullable column | Yes | Rarely | MySQL 8.4 often uses INPLACE; still watch replica lag. |
| Add index on large table | Sometimes | Often | Use ALGORITHM=INPLACE on MySQL; test on a snapshot first. |
| Rename column | No | Yes | Old code breaks immediately unless you dual-read. |
| Change column type | No | Yes | Casts and truncation can fail silently or lock. |
| Add NOT NULL to populated column | No | Yes | Backfill defaults before enforcing constraint. |
| Drop column in use | No | Yes | Deploy code that stops reading it first. |
| New table | Yes | No | Safe if old code ignores it. |
Official Laravel migration docs describe syntax. They do not guarantee online behaviour on your row volume. Cross-check engine notes in the MySQL 8.4 online DDL documentation or PostgreSQL’s concurrent index docs before you schedule a Friday deploy.
Indexes without locking reads
Adding an index on a bookings or orders table can take minutes. On MySQL, raw SQL sometimes beats the abstraction:
DB::statement('ALTER TABLE orders ADD INDEX idx_status_created (status, created_at), ALGORITHM=INPLACE, LOCK=NONE');
On PostgreSQL 18, create indexes concurrently outside a Laravel transaction:
DB::statement('CREATE INDEX CONCURRENTLY idx_orders_status_created ON orders (status, created_at)');
Laravel wraps migrations in transactions on PostgreSQL by default. Concurrent index creation cannot run inside that transaction. Use DB::unprepared() in a dedicated migration file, or run the statement manually in a controlled window. Read database indexing for performance before you add partial or composite indexes under load.
How do you run migrations during a zero-downtime deploy?
Code deploy and schema deploy must be ordered. I run backward-compatible migrations before the symlink swap on sites that share a Deployer 7 + GitLab CI pipeline. Sister legal-tech properties on shared EC2 follow the same pattern described in zero-downtime deployment with Deployer for PHP apps.
A typical Deployer 7 hook sequence:
/* deploy.php */
task('artisan:migrate', function () {
run('{{bin/php}} {{release_path}}/artisan migrate --force --no-interaction');
});
before('deploy:symlink', 'artisan:migrate');
after('deploy:symlink', 'php-fpm:reload');
That order works when migrations only expand schema. Contract migrations that drop columns run only after you confirm zero traffic on the old release. I keep a deploy:check task that fails if a migration contains dropColumn on a table tagged critical unless an env flag is set.
Queues, workers, and long migrations
Queue workers cache booted code. After deploy, restart them or they keep using old Eloquent attribute lists. I restart Supervisor groups right after PHP-FPM reload. Long backfills belong in queued jobs, not migration up() methods. A migration that updates five million rows will exceed deploy timeouts and hold connections.
Use php artisan queue:work --max-jobs=500 --max-time=3600 during backfill windows. Monitor failed jobs and deadlocks. Laravel transactions and deadlocks covers chunk sizing when many workers write the same table.
What production safeguards belong beside zero-downtime migrations?
Migrations are destructive even when they are “online.” Treat every production run like a incident waiting to happen unless you can roll back data, not just code.
- Backup before expand and contract: Automate dumps with Spatie Laravel Backup or nightly cron. Restore tests matter; see database restore testing you should actually do.
- Staging mirror with realistic row counts: A 200-row clone hides lock times that appear at 2 million rows.
- Migration timing flags: Block contract migrations in peak hours for Nepal traffic or international clients.
- Feature flags for read paths: Toggle new column reads without redeploying when possible.
- Replica lag alerts: Online DDL on primary can lag read replicas; see read replicas for Laravel setup.
On booking systems like Adventure Third Pole Trek, checkout flows touch orders, payments, and inventory. A half-deployed schema there produces partial payments and support tickets. Expand-contract is slower. It matches how those apps actually earn revenue.
For large MySQL tables where even INPLACE adds risk, external tools such as Percona pt-online-schema-change copy rows through triggers. Laravel records the change in a migration file that documents manual steps. Honesty in migration comments saves the next developer from running destructive SQL twice.
Testing migrations in CI
Run migrations against a fresh database on every pipeline branch. Also run them against a anonymised snapshot weekly. Laravel 13 expects PHP 8.3 or higher; Laravel 12 runs on PHP 8.2+. Match CI PHP versions to production or you miss syntax and extension errors.
php artisan migrate:fresh --seed --env=testing
php artisan test --parallel
Validate JSON column payloads with the site JSON formatter when migrations reshape API-facing structures. Small tooling catches contract breaks before deploy.
How do you handle rollback when a zero-downtime migration fails?
Code rollback is one command: dep rollback. Schema rollback is rarely symmetric. Dropping a column you already populated loses data. That is why contract phases ship days after expand phases.
Keep down() methods for development and staging. In production, prefer forward-fix migrations instead of migrate:rollback on shared tables. If an expand migration adds primary_email and a bug fills wrong values, ship a corrective job. Do not drop the column while v2 still depends on it.
Reference the Laravel 12.x migrations documentation for anonymous migration classes and squashing in greenfield apps. Squashing history is fine before launch. It is dangerous on long-lived production schemas where audit trails matter.
When upgrading frameworks, schema compatibility and migration order interact. Plan framework upgrades separately from contract phases when possible. Laravel 12 migration guide from Laravel 10 covers PHP version bumps that affect deploy timing.
Automate backup verification before contract migrations. On Court Marriage In Nepal and similar lead-capture portals, downtime during a column drop is visible in Search Console within hours. Pair schema work with support and maintenance windows when stakeholders expect zero errors.
For enterprise apps with stricter SLAs, document each migration in the pull request: expand deploy date, backfill job name, read switch release, contract deploy date. Ops teams and future you will need that map. Database migrations at scale expands the same ideas for multi-tenant schemas.
Key Takeaways
- Split risky changes into expand, dual-write, backfill, read switch, and contract releases — never rename hot columns in one deploy.
- Run expand migrations before the Deployer symlink swap; delay contract migrations until old code is fully drained.
- Keep heavy data backfills in queued jobs with chunked updates, not inside migration
up()methods. - Test against realistic row counts, verify backups, and monitor replica lag before adding indexes on large tables.
- Prefer forward-fix migrations in production over
migrate:rollbackwhen data has already moved to new columns. - Restart queue workers after deploy so they stop reading dropped or renamed attributes from stale boot cache.
People Also Ask
Can Laravel migrations run without downtime on MySQL?
Yes, when you add nullable columns or new tables and when MySQL uses online DDL with INPLACE and LOCK=NONE. Renames, type changes, and NOT NULL enforcement on populated columns still need expand-contract across multiple deploys.
Should migrations run before or after code deployment?
Run backward-compatible expand migrations before switching traffic to new code. Run contract migrations that drop columns only after every worker and user session uses code that no longer reads the old schema.
Does php artisan migrate --force lock tables?
Laravel itself does not lock tables. The SQL your migration generates does. Large ALTER TABLE statements on InnoDB can block writes for seconds or minutes depending on algorithm, table size, and metadata locks.
How many deploys does a zero-downtime column rename take?
Plan for three to five releases: add column, dual-write, backfill, switch reads, drop old column. Small internal apps sometimes compress steps. Production apps with continuous traffic should not.
Ship schema changes without taking the site offline
Zero-downtime Laravel database migrations are a deploy discipline, not a Composer package. Expand-contract steps, ordered Deployer hooks, chunked backfills, and delayed contract phases keep PHP 8.3+ apps online while MySQL or PostgreSQL evolves underneath. Start with one hot table, document the release sequence, and automate backups before you drop anything.
If your team needs hands-on help planning migrations for a live Laravel app, review the enterprise application development and Linux administration services, browse related work in the portfolio, or contact us to walk through your next release safely.
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.

