Kokil Thapa - Professional Web Developer in Nepal
Freelancer Web Developer in Nepal with 15+ Years of Experience

Kokil Thapa is an experienced full-stack web developer focused on building fast, secure, and scalable web applications. He helps businesses and individuals create SEO-friendly, user-focused digital platforms designed for long-term growth.

Zero-Downtime Laravel Database Migrations

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.

Zero-Downtime Migration TimelineExpandAdd nullable columnDeploy v2Dual-write both fieldsBackfillQueue job fills dataContractDrop old columnTraffic stays online throughoutApp v1 reads oldBoth versions OKApp v2 uses newOld removedNever rename-in-place on live tables with active traffic
Zero-downtime Laravel database migrations use expand, deploy, backfill, and contract phases instead of one atomic rename.

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.

  1. Expand: Add the new column, index, or table. Keep it nullable or unused.
  2. Dual-write / dual-read: Ship code that writes to both old and new fields when needed.
  3. Backfill: Copy or transform existing rows with a queued job or Artisan command.
  4. Switch reads: Deploy code that reads the new field as the source of truth.
  5. 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.

ChangeUsually safe onlineNeeds expand-contractNotes
Add nullable columnYesRarelyMySQL 8.4 often uses INPLACE; still watch replica lag.
Add index on large tableSometimesOftenUse ALGORITHM=INPLACE on MySQL; test on a snapshot first.
Rename columnNoYesOld code breaks immediately unless you dual-read.
Change column typeNoYesCasts and truncation can fail silently or lock.
Add NOT NULL to populated columnNoYesBackfill defaults before enforcing constraint.
Drop column in useNoYesDeploy code that stops reading it first.
New tableYesNoSafe 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.

Blocking vs Zero-DowntimeBlocking renameALTER TABLE locks writesDeploy + migrate together500 errors during lockFast but risky on live trafficExpand-contractAdd column onlineCode switch + backfillDrop old laterMore deploys, no user outage
Zero-downtime Laravel database migrations trade extra releases for predictable uptime on busy tables.

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.

Deployer 7 Release FlowGit pullComposerMigrateSymlinkReloadShared paths persist across releasesshared/.envsecrets unchangedstorage/uploads persistdatabasesingle schemaPHP-FPM reload clears opcache after symlink swap
Run expand-phase zero-downtime Laravel database migrations before the Deployer symlink swap so new code finds ready schema.

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.

Migration Strategy DecisionSchema change needed?Small / new tableSingle migration OKLarge hot tableExpand-contractRename or type change?Never in-placeStill monitor locksand replica lagDocument in migration PRList deploy order for team
Choose zero-downtime Laravel database migrations strategies based on table heat, change type, and rollback needs.

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.

Failure Recovery PatternBad backfilldetected in logsPause contractkeep both columnsFix jobcorrect rowsResumenormal deployCode rollback alone does not undo schemaAvoid migrate:rollbackForward-fix migrationRestore from backupAutomated backups via Spatie or nightly mysqldump
Recover zero-downtime Laravel database migrations with forward fixes and paused contract steps, not destructive rollbacks.

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:rollback when 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

Schema changes shipped in compatible steps so old and new app versions run safely together during deploy, without failed requests from table locks or schema drift.

Yes, when you add nullable columns or new tables and MySQL 8.4 uses online DDL with INPLACE and LOCK=NONE. Renames, type changes, and NOT NULL on populated columns still need expand-contract across multiple deploys.

Run backward-compatible expand migrations before the Deployer symlink swap. Run contract migrations that drop columns only after every worker and session uses code that no longer reads the old schema.

Expand-contract is the core zero-downtime technique. You expand schema first by adding nullable columns or tables, dual-write in application code, backfill existing rows, switch reads to the new field, then contract by dropping old structures in a later release. Each step is its own deploy. Old code must keep working after the migration, and new code must work before and after it. If either side breaks, you need maintenance mode or accept errors.

Plan for three to five releases: add the new column, ship dual-write code, run a chunked backfill job, switch reads to the new column, then drop the old column. The article’s users.email to primary_email example uses five deploys. Small internal apps sometimes compress steps, but production apps with continuous traffic should not. Never use renameColumn on a hot table in one step.

Laravel’s migration runner does not lock tables itself. 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. A deploy that runs migrate --force against a live database can lock tables and break requests mid-flight. That is why zero-downtime work treats schema changes as a release strategy, not a single SQL file.

Adding nullable columns and new tables are usually safe if the table is small or the engine supports online DDL. Adding indexes on large tables is sometimes safe with INPLACE on MySQL, but often needs expand-contract. Renames, column type changes, adding NOT NULL to populated columns, and dropping columns in use always need multi-release expand-contract. Official Laravel migration docs describe syntax but do not guarantee online behaviour at your row volume.

On MySQL 8.4, raw SQL with ALGORITHM=INPLACE and LOCK=NONE often beats Schema builder on hot orders or bookings tables. On PostgreSQL 18, use CREATE INDEX CONCURRENTLY outside Laravel’s default transaction via DB::unprepared or a manual controlled window, because concurrent index creation cannot run inside a transaction. Test on a snapshot first and monitor replica lag before scheduling production index work.

Run backward-compatible expand migrations before the symlink swap. A typical Deployer 7 hook runs artisan migrate --force --no-interaction before deploy:symlink, then reloads PHP-FPM after. Contract migrations that drop columns run only after you confirm zero traffic on the old release. Keep a deploy:check task that fails if a migration contains dropColumn on a critical table unless an env flag is set.

Queue workers cache booted code. After deploy, workers that are not restarted keep using old Eloquent attribute lists and may read dropped or renamed columns from stale boot cache. Restart Supervisor groups right after PHP-FPM reload on sites using Deployer 7 and GitLab CI. Long backfills belong in queued jobs with chunked updates, not inside migration up() methods that exceed deploy timeouts.

Never inside migration up() methods on production. A migration updating millions of rows will exceed deploy timeouts and hold connections. Dispatch chunked backfill jobs instead, for example chunkById in batches of 500, and run queue workers with max-jobs and max-time limits during backfill windows. Monitor failed jobs and deadlocks. On booking systems touching orders, payments, and inventory, half-deployed schema produces partial payments and support tickets.

Code rollback is one command with dep rollback, but schema rollback is rarely symmetric. Dropping a column you already populated loses data, which is why contract phases ship days after expand phases. In production, prefer forward-fix migrations and corrective jobs over migrate:rollback on shared tables. If an expand migration adds a column and a bug fills wrong values, ship a fix job rather than dropping the column while v2 still depends on it.

Automate backups before expand and contract phases with tools like Spatie Laravel Backup or nightly cron, and verify restores actually work. Test against staging mirrors with realistic row counts, not 200-row clones that hide lock times at two million rows. Block contract migrations in peak hours, use feature flags for read paths where possible, and monitor replica lag after online DDL on the primary. Document expand date, backfill job name, read switch release, and contract date in each pull request.

Run migrations against a fresh database on every pipeline branch with migrate:fresh --seed --env=testing, plus parallel tests. Also run migrations weekly against an anonymised production snapshot to catch lock times hidden on small datasets. Match CI PHP versions to production: Laravel 13 expects PHP 8.3 or higher, Laravel 12 runs on PHP 8.2 or higher. Validate JSON column payloads when migrations reshape API-facing structures.

Always on hot production tables with continuous traffic. renameColumn in one step breaks old code immediately unless you dual-read, which is why the article never uses it on live users tables. Expand-contract adds the new column nullable, mirrors writes via an observer or mutator, backfills in chunks, switches reads, then drops the old column across separate releases. It is slower but prevents login outages on steady-traffic legal-tech and booking portals.

Share this article

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.

Quick Contact Options
Choose how you want to connect me: