
September 07, 2026
15 min read
By Kokil Thapa | Last reviewed: September 2026
Production Laravel apps do not get maintenance windows anymore. Users hit booking forms, payment callbacks, and document uploads at all hours — especially on legal-tech and eCommerce platforms where a failed migration means lost orders or stuck client files. Laravel migrations best practices for zero downtime are therefore not a DevOps luxury; they are how you ship schema changes without taking the site offline or breaking the running code. This guide walks through the patterns I use on production Laravel 12 applications with PHP 8.3+, MySQL 9.7, and PostgreSQL 18, aligned with the same zero-downtime deployment workflow with Deployer that keeps symlink swaps and database changes in the right order.
What makes a Laravel migration unsafe for zero downtime deployments?
A migration is unsafe when the old application code — still serving live traffic during your deploy — cannot read or write the database after the migration runs. Laravel's migration runner executes SQL immediately; there is no built-in blue/green schema layer. If you add a NOT NULL column without a default, drop a column your current controllers still select, or rename a column in one step, the running PHP-FPM workers throw exceptions the moment opcache serves the previous release against the new schema.
On a real client project running Laravel 12 with queued jobs, I have seen a single ->change() on a large MySQL table lock the table long enough to stall webhook retries and duplicate payment callbacks. The application did not crash entirely, but background workers piled up until Redis memory spiked. That is downtime in practice, even if the homepage still loaded.
The following operations are high-risk during live traffic:
- Renaming columns or tables — old code references the old name; new code references the new name. There is no overlap window unless you duplicate data temporarily.
- Dropping columns, indexes, or tables — any lingering reference in the current release causes SQL errors.
- Adding non-nullable columns without defaults — inserts from old code fail because they omit the new field.
- Changing column types in place — especially on large MySQL InnoDB tables,
ALTER TABLEcan rebuild the table and hold metadata locks. - Tightening constraints — adding a unique index or foreign key can reject rows the old code still creates.
- Data backfills inside the migration transaction — updating millions of rows blocks deploy time and holds locks.
Safe migrations are additive first. You add columns, indexes, and tables; you defer destructive work until a later release when no running code depends on the old shape. This mirrors the broader guidance in database migrations and seeding best practices in Laravel, but zero-downtime work adds a release-ordering constraint that local development often hides.
How locking differs between MySQL and PostgreSQL
MySQL 9.7 (and the widely deployed 8.4 LTS line) uses InnoDB metadata locks during many ALTER operations. An apparently innocent $table->string('status')->change() through doctrine/dbal can trigger a full table rebuild on older configurations. PostgreSQL 18 is generally more permissive for ADD COLUMN with defaults — PostgreSQL 11+ stores defaults efficiently without rewriting every row — but concurrent index builds and VALIDATE CONSTRAINT steps still need planning. If you are evaluating engines, the PostgreSQL guide for Laravel developers covers operational differences that affect migration timing.
How do you expand and contract database schema changes safely?
The expand-contract pattern (also called parallel change) is the core technique behind every reliable zero-downtime migration. You treat the database schema like an API versioned across releases: expand compatibility, migrate application behaviour, then contract obsolete structures.
Example: renaming phone to mobile_number
Never use $table->renameColumn('phone', 'mobile_number') in a single deploy if production traffic exists. Instead, spread the work across migrations and releases:
Release 1 — expand migration:
/* database/migrations/2026_09_01_000001_add_mobile_number_to_users.php */
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
$table->string('mobile_number', 20)->nullable()->after('phone');
$table->index('mobile_number');
});
}
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropIndex(['mobile_number']);
$table->dropColumn('mobile_number');
});
} Release 1 — application code: write to both columns on save; read from mobile_number with fallback:
protected function mobileNumber(): string
{
return $this->mobile_number ?? $this->phone ?? '';
}
protected static function booted(): void
{
static::saving(function (User $user) {
if ($user->isDirty('mobile_number') || $user->isDirty('phone')) {
$value = $user->mobile_number ?? $user->phone;
$user->mobile_number = $value;
$user->phone = $value;
}
});
} Between releases — backfill via queued job:
User::query()
->whereNull('mobile_number')
->whereNotNull('phone')
->orderBy('id')
->chunkById(500, function ($users) {
foreach ($users as $user) {
$user->forceFill(['mobile_number' => $user->phone])->saveQuietly();
}
}); Release 2 — contract: remove dual-write logic, then ship a separate migration dropping phone. On platforms like Adventure Third Pole Trek, where booking records must stay consistent across deploys, this staged approach prevented broken contact lookups during peak season traffic.
Adding a non-nullable column safely
- Add the column as
nullable()in migration release 1. - Deploy code that always sets the column on new records.
- Run a batched backfill job to populate existing rows.
- Add a migration in release 2 that sets the default and changes to
nullable(false)— or keep it nullable if business rules allow unknowns.
On PostgreSQL you can sometimes add a column with a constant default in one step without a full rewrite, but the two-release pattern still protects you when old code omits the field on insert. The official Laravel 12.x migration documentation covers syntax; production safety comes from release ordering, not from the framework alone.
Which Laravel migration patterns break production under load?
Local SQLite or empty MySQL databases lie to you. These patterns pass php artisan migrate on a laptop and fail under production row counts, replication lag, or concurrent queue workers.
| Pattern | Why it breaks live traffic | Zero-downtime alternative |
|---|---|---|
renameColumn() in one deploy | Old PHP still queries old name; immediate 500 errors | Add new column, dual-write, backfill, drop old |
->change() on large tables | Long metadata locks; blocked reads/writes on MySQL | Add new column with target type; copy in batches; swap |
Heavy UPDATE in up() | Deploy timeout; transaction locks | Queue job with chunkById(); idempotent batches |
| Drop index before code stops using it | Slow queries or full scans spike CPU | Remove index usage in code first; drop index next release |
| Foreign key on existing dirty data | Migration fails mid-deploy; partial state | Clean data in app/job first; add FK as NOT VALID on PostgreSQL |
| Enum/string status rewrite | Old code writes legacy values new constraint rejects | Add new column or widen allowed values; migrate values; contract |
A common mistake is running Schema::disableForeignKeyConstraints() globally to force a migration through. That hides data integrity problems and creates orphan rows that surface weeks later in reports. Validate data before constraints, not after.
Index creation without blocking writers
Adding an index on a multi-million-row table during deploy can stall writes. On PostgreSQL, use raw SQL for concurrent index creation:
DB::statement('CREATE INDEX CONCURRENTLY idx_orders_status ON orders (status)'); MySQL 8.4+ and 9.7 support ALGORITHM=INNOBT and LOCK=NONE for some online DDL operations, but not every index type qualifies. Test against a production-sized snapshot — a service like testing and optimization should include migration rehearsal, not just page-speed audits. For JSON column indexing during API growth, cross-read Laravel API best practices so index strategy matches query patterns.
How should you run Laravel migrations in a CI/CD pipeline?
Migration timing relative to code swap is the difference between zero downtime and accidental outage. The sequence I use with Deployer 7 on shared EC2 infrastructure — the same pattern behind several sister legal-tech sites — follows a strict order:
- Put the app in maintenance mode only if you have no backward-compatible path (rare if you follow expand-contract).
- Run
php artisan migrate --forceagainst the shared database before switching thecurrentsymlink to the new release. - Reload PHP-FPM to clear opcache so new code loads atomically.
- Restart queue workers with
php artisan queue:restartso job classes match the schema. - Verify health checks, then drain maintenance mode.
Running migrations after the symlink swap with breaking schema changes means new code hits old schema for seconds to minutes — enough to fail payment callbacks. Running destructive migrations before old code stops is equally dangerous. The expand-contract rule resolves this tension: every migration at deploy time must be compatible with both the incoming and outgoing release.
Sample Deployer 7 task ordering:
task('deploy:migrate', function () {
run('{{bin/php}} {{release_path}}/artisan migrate --force');
});
before('deploy:symlink', 'deploy:migrate');
after('deploy:symlink', 'php-fpm:reload');
after('deploy:symlink', 'artisan:queue:restart'); GitLab CI should run migrations only on the deploy runner with production credentials — never from arbitrary branch pipelines. Staging must replay the same migration sequence against anonymised production volume data weekly. Details on pipeline hardening sit in build pipeline automation best practices and the dedicated team migration workflow guide.
Feature flags and long-running backfills
When backfills take hours, gate read-path switches behind a config flag or .env value loaded through config:cache:
// config/features.php
return [
'use_mobile_number_column' => env('FEATURE_MOBILE_NUMBER_READ', false),
]; Deploy the flag defaulting to false, finish the backfill, flip the flag in the shared .env without redeploying, then ship code that removes the legacy read path in the next release. This decouples data readiness from code rollout — critical on Nepal Gift Card where order records cannot afford inconsistent reads during gateway reconciliation.
What is the correct rollback strategy for zero downtime migrations?
Laravel's migrate:rollback is not a production undo button. Rollback assumes your down() methods are tested, that no new data depends on expanded schema, and that you can redeploy old code instantly. In practice, rollback fails when:
down()drops a column that new code already wrote to.- Data backfills are irreversible without snapshots.
- Concurrent index migrations have no symmetric
down()on PostgreSQL. - Multiple deploys landed since the migration; old code no longer exists in the release directory.
The safer production strategy is roll forward: fix bad data or ship a corrective migration rather than reversing schema. Treat down() as a developer convenience for local resets, not a disaster recovery plan. Database snapshots — nightly logical dumps plus binlog/WAL archiving — remain the real safety net. For infrastructure-level backup cadence, align with Linux system administration practices: test restores monthly, not just backups.
Writing reversible migrations where it matters
For local and staging, keep down() honest:
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
if (Schema::hasColumn('users', 'mobile_number')) {
$table->dropIndex(['mobile_number']);
$table->dropColumn('mobile_number');
}
});
} Never drop production columns in down() if any release still reads them. Tag destructive migrations in commit messages and runbooks so the team knows they require a code rollback first. When upgrading framework versions, follow the staged path in the Laravel 12 migration guide from Laravel 10 — framework upgrades and schema migrations should not ride the same Friday deploy without rehearsal.
Team coordination and migration naming
Parallel developers merging conflicting migrations is a silent zero-downtime killer. Use timestamp prefixes, never edit applied migrations, and squash only on greenfield branches. Enforce php artisan migrate:status in CI after merge. Larger architectural shifts — splitting monolith tables before a service extraction — belong in modern Laravel architecture practices and possibly monolith-to-microservices migration strategy planning before you touch production DDL.
Validate generated SQL in staging with tools like the JSON formatter for API fixture checks alongside manual query review — automated schema diff tools catch missing indexes that migrations imply but do not create. Security-sensitive columns (tokens, document paths) should follow OWASP-aligned Laravel hardening before you add indexes that leak timing side channels through error messages.
How do you test migrations before they touch production?
Zero-downtime confidence comes from rehearsal, not hope. A practical pre-production checklist:
- Clone production volume — anonymise PII, restore to staging MySQL 9.7 or PostgreSQL 18 matching production engine settings.
- Measure migration duration —
time php artisan migrate --forcewithgeneral_logorpg_stat_activitywatching lock waits. - Run old and new code — keep previous release checkout pointed at post-migration schema; hit critical endpoints and queue jobs.
- Simulate rollback path — document roll-forward steps even if you skip
down(). - Schedule low-traffic window — for Nepal-based clients, post-midnight NPT still beats peak evening traffic, even with zero-downtime patterns.
On legal-tech portals where document uploads spike before court deadlines, I schedule contract-phase drops outside filing peaks. The operational mindset matches enterprise application development: migrations are release events, not DBA side tasks.
MySQL's online DDL limitations are documented in the MySQL 9.7 InnoDB online DDL reference. PostgreSQL concurrent index behaviour is covered in the PostgreSQL 18 CREATE INDEX documentation. Read both before assuming Laravel's schema builder maps to online operations on your table size.
Key Takeaways
- Every production migration must stay compatible with the currently live code release — expand first, contract later.
- Never rename or drop columns in the same deploy that introduces code depending on the new shape; use dual-write and queued backfills.
- Run
migrate --forcebefore symlink swap for expand-phase migrations; restart queue workers immediately after. - Prefer roll-forward fixes and tested backups over
migrate:rollbackin production. - Rehearse migrations against production-sized staging data and measure lock duration before merge.
- Tag destructive migrations in team runbooks and coordinate with clean Laravel coding practices so models do not hide legacy columns prematurely.
People Also Ask
Can Laravel migrations run automatically during zero downtime deploys?
Yes, but only when each migration is backward-compatible with the running release. Automate php artisan migrate --force in Deployer, Envoy, or GitLab CI before switching traffic to new code. Disable auto-migrate on destructive steps until old code is fully retired across all workers and cron hosts.
Should you use doctrine/dbal for column changes in production?
Use it sparingly. ->change() introspects the table and may rewrite it entirely on MySQL, causing long locks. Prefer adding a new column, copying data in batches, and dropping the old column in a later release. If you must change in place, test the generated SQL on a full-size clone first.
How long should you wait between expand and contract releases?
Wait until three conditions hold: backfill jobs finished, no errors referencing legacy columns in logs, and queue depth normalised. For high-traffic systems that can mean hours; for low-traffic admin apps, one deploy cycle may suffice. Do not contract on a timer — contract on evidence.
Do Laravel migrations block Redis queues and Horizon?
Migrations do not stop Redis, but schema mismatches crash job handlers mid-batch. Always run php artisan queue:restart after deploy so workers reload classes. Long-running jobs started before migrate may still use old assumptions — design jobs to tolerate additive schema during expand phases.
Ship schema changes without stopping traffic
Laravel migrations best practices for zero downtime boil down to one discipline: treat the database like a versioned API consumed by multiple simultaneous code releases. Add before you remove, backfill outside deploy transactions, migrate before symlink swap, and contract only when metrics prove the old path is dead. Pair that with rehearsed pipelines — the same mindset behind SEO-safe migration checklists — and you stop trading five minutes of schema work for hours of incident response.
If your Laravel app still takes maintenance mode for routine column additions, the fix is process and migration design, not a bigger server. Review our client portal work, browse customer reviews, or support and maintenance services if you want help auditing your deploy pipeline. For a full migration and deploy assessment, contact us with your current release process and stack — Laravel 12, PHP 8.3+, and MySQL or PostgreSQL.
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.

