How to Handle Database Migrations in a Laravel App Without Losing Data

How to Handle Database Migrations in a Laravel App Without Losing Data

by | Nov 3, 2026 | Uncategorized | 0 comments

Every PHP developer has a story about the migration that took the site down. A dropColumn that ran before the new code was deployed. An ALTER TABLE on a 40 million row table that locked writes for eleven minutes. A backfill loop inside a migration that exhausted memory halfway through and left the data in a half-updated state. It is argued more carefully on laracasts.com.

Running a Laravel database migration in production is not the same exercise as running one on your laptop. Locally you can always migrate:fresh. In production, that command is a career event. This guide is the repeatable process our team at Box Software uses on live applications: how to write reversible schema changes, how to backfill columns in batches without blocking the database, and how to deploy structural changes with zero downtime.

Why Laravel Migrations Break Production (and Why It Is Rarely Laravel’s Fault)

Laravel migrations are just a thin, versioned wrapper around DDL statements. The framework runs what you tell it to run. Production incidents almost always come from a small, predictable set of causes:

  • Destructive changes shipped too early. The column is dropped while the old code, still running on half the servers, keeps selecting it.
  • Locking DDL on large tables. Some ALTER TABLE operations rewrite the whole table and block writes for the duration.
  • Data manipulation inside schema migrations. A loop over 2 million rows inside up() that times out, cannot resume and cannot be rolled back.
  • Missing or fake down() methods. The rollback path was never written, never tested, or silently does nothing.
  • Schema drift. Someone applied a manual hotfix directly on the production database, so the migration that works on staging fails on production.
  • No backup checkpoint. The last verified restore was months ago and nobody actually knows if it works.

Fix those six and production migrations become boring, which is exactly what you want.

database server code

Step 1: The Pre-Flight Checklist

Before any migration touches a live database, run through this list. It takes five minutes and prevents most incidents.

Check Command or action
Fresh, verified backup Take a snapshot and confirm it restores on a scratch instance
Know what will run php artisan migrate --pretend
Know the pending list php artisan migrate:status
Know the row counts php artisan db:table users and php artisan db:show
Detect schema drift Diff production schema against your committed schema/*.sql dump
Rollback plan written Tested down() or a documented forward fix
Deploy order agreed Migration before code, or code before migration (see Step 3)

Use –pretend as a Code Review Tool

The single most underused flag in the Artisan toolbox converts every pending migration into raw SQL, prints it and stops without touching the database:

php artisan migrate --pretend

Run it against a copy of production (or production itself, it is read-only in intent) and paste the output into the pull request. Reviewers judge the actual SQL, not your intention. If a statement looks like a full table rewrite, you will see it here. Related reading: Leveraging Laravel Migrations for Cloud Deployments.

Step 2: Write Genuinely Reversible Migrations

A migration without a working down() is a one-way door. Laravel can guess reversals for simple cases, but you should write them explicitly.

<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    public function up(): void
    {
        Schema::table('invoices', function (Blueprint $table) {
            $table->string('reference', 32)->nullable()->after('id');
            $table->index('reference', 'invoices_reference_index');
        });
    }

    public function down(): void
    {
        Schema::table('invoices', function (Blueprint $table) {
            $table->dropIndex('invoices_reference_index');
            $table->dropColumn('reference');
        });
    }
};

Rules for Reversibility

  • Always name your indexes and foreign keys. Auto-generated names differ between drivers and make down() fragile.
  • Never put a destructive change and a creative change in the same migration. One concern per file keeps rollbacks surgical.
  • Make the migration idempotent where it is cheap to do so using Schema::hasColumn(), Schema::hasTable(), Schema::whenTableDoesntHaveColumn(). This saves you when a migration fails halfway and gets re-run.
  • Accept that some changes are not reversible. If you drop a column with data, down() can recreate the column but not the values. Say so in a comment, and rely on the backup instead.
public function up(): void
{
    Schema::whenTableDoesntHaveColumn('orders', 'currency', function (Blueprint $table) {
        $table->char('currency', 3)->default('EUR');
    });
}

Control Transaction Behaviour

PostgreSQL supports transactional DDL, so a failed migration rolls back cleanly. MySQL does not: a failed multi-statement migration leaves you in a half-applied state. For operations that must not run inside a transaction (such as CREATE INDEX CONCURRENTLY on PostgreSQL), disable it on the migration class:

return new class extends Migration
{
    public $withinTransaction = false;

    public function up(): void
    {
        DB::statement('CREATE INDEX CONCURRENTLY idx_orders_customer_id ON orders (customer_id)');
    }

    public function down(): void
    {
        DB::statement('DROP INDEX CONCURRENTLY IF EXISTS idx_orders_customer_id');
    }
};
database server code

Step 3: The Expand and Contract Pattern

This is the core technique for zero-downtime schema changes. Instead of one deploy that changes the schema and the code simultaneously, you split the work into phases where old code and new schema are always compatible.

Take the classic example: renaming users.name to users.full_name. A direct rename breaks every running process the moment it executes. Here is the safe sequence.

Phase Database Application code
1. Expand Add nullable full_name Unchanged, still reads name
2. Dual write No change Writes both columns, reads name
3. Backfill Batched copy of old values Unchanged
4. Switch reads No change Reads full_name, still writes both
5. Stop dual write No change Only full_name
6. Contract Drop name Unchanged

Yes, that is up to four deploys for one rename. It is also the reason nobody gets paged at 2am. Run phase 6 days or weeks later, once you are certain no queued job, scheduled command or cached view still references the old column.

Practical Expand and Contract Rules

  1. New columns are always nullable or have a default in the expand phase. A NOT NULL column with no default on a populated table will fail or lock.
  2. Never drop or rename in the same release as the code change. Deploys are not atomic, and rolling deployments run two versions of your code at once.
  3. Add constraints last. Backfill the data first, then promote the column to NOT NULL or add the unique index.
  4. Queued jobs are old code. A job serialized before the deploy may be executed after it. Treat your queue as a second, lagging application version.

Step 4: Backfill Data in Batches, Outside the Migration

This is the rule that saves the most incidents: schema migrations change structure, Artisan commands change data. Putting a mass update inside up() gives you no progress reporting, no resumability, no rate limiting and no safe rollback.

Why a Command Beats a Migration for Data

  • You can run it manually, at a chosen hour, with monitoring open.
  • You can stop it, fix the logic and resume from the last processed ID.
  • You can throttle it so replication lag stays under control.
  • Your deploy pipeline is not held hostage by a 40 minute data operation.

A Resumable Batched Backfill Command

<?php

namespace App\Console\Commands;

use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;

class BackfillInvoiceReferences extends Command
{
    protected $signature = 'backfill:invoice-references
                            {--chunk=1000 : Rows per batch}
                            {--sleep=200 : Milliseconds to pause between batches}
                            {--from-id=0 : Resume from this primary key}';

    protected $description = 'Backfill invoices.reference in safe batches';

    public function handle(): int
    {
        $chunk    = (int) $this->option('chunk');
        $sleep    = (int) $this->option('sleep');
        $lastId   = (int) $this->option('from-id');
        $total    = 0;

        do {
            $rows = DB::table('invoices')
                ->select('id', 'created_at')
                ->whereNull('reference')
                ->where('id', '>', $lastId)
                ->orderBy('id')
                ->limit($chunk)
                ->get();

            if ($rows->isEmpty()) {
                break;
            }

            DB::transaction(function () use ($rows) {
                foreach ($rows as $row) {
                    DB::table('invoices')
                        ->where('id', $row->id)
                        ->update([
                            'reference' => sprintf('INV-%s-%06d', date('Y', strtotime($row->created_at)), $row->id),
                        ]);
                }
            });

            $lastId = $rows->last()->id;
            $total += $rows->count();

            $this->info("Processed {$total} rows, last id {$lastId}");

            usleep($sleep * 1000);
        } while (true);

        $this->info("Backfill complete. {$total} rows updated.");

        return self::SUCCESS;
    }
}

Key details that make this production safe:

  • Keyset pagination (where id > lastId) instead of offset, so performance stays constant on huge tables.
  • The filter is the work itself (whereNull('reference')), which makes the command naturally idempotent and restartable.
  • A deliberate pause between batches gives replicas time to catch up and leaves IO for real traffic.
  • Small transactions per batch instead of one giant transaction holding locks for minutes.

If your transformation is expensive (API calls, image processing, re-encryption), dispatch a queued job per batch instead and let Horizon handle concurrency and retries.

When the Set-Based Update Is Better

If the value can be computed entirely in SQL, a batched single statement is far faster than row-by-row PHP:

DB::statement('
    UPDATE orders
    SET total_cents = ROUND(total * 100)
    WHERE total_cents IS NULL
    ORDER BY id
    LIMIT 5000
');

Loop that statement until the affected row count is zero, with a short sleep between iterations.

Step 5: Know Which Operations Lock Your Table

Not every ALTER TABLE is dangerous, and the answer depends on your engine. Use this as a rough map, then verify on a production-sized copy.

Operation MySQL 8 / InnoDB PostgreSQL 14+
Add nullable column Instant in most cases Instant (metadata only)
Add column with default Instant (8.0.12+) Instant (11+)
Add index Online, writes allowed, but heavy IO Blocks writes unless CONCURRENTLY
Change column type Usually full table rebuild Usually full table rewrite with ACCESS EXCLUSIVE lock
Rename column Fast, but breaks running code Fast, but breaks running code
Drop column Instant drop (8.0.29+) or rebuild Instant (metadata only)
Add foreign key Validates existing rows, can be slow Use NOT VALID then VALIDATE CONSTRAINT

Two Protective Habits

  • Set a lock timeout so a migration fails fast instead of queueing every other query behind it. On PostgreSQL: SET lock_timeout = '5s'; before the DDL. On MySQL: SET SESSION lock_wait_timeout = 10;.
  • Use an online schema change tool for very large MySQL tables, such as pt-online-schema-change or gh-ost. Generate the SQL with migrate --pretend, run it through the tool, then mark the migration as applied.
database server code

Step 6: Running the Migration on the Live Database

In a non-interactive production environment Laravel will refuse destructive prompts, so your deploy script needs the right flags.

# Standard production deploy step
php artisan migrate --force

# Multi-server deploy: only one node acquires the lock and runs them
php artisan migrate --force --isolated

# Apply one migration at a time so each gets its own batch for rollback
php artisan migrate --force --step

Flags Worth Knowing

Flag What it does
--force Skips the “are you sure” prompt in production. Required in CI/CD.
--pretend Prints the SQL instead of executing it.
--isolated Uses an atomic lock so concurrent deploys do not run migrations twice.
--step Records each migration in its own batch for granular rollback.
--graceful Exits without an error code on failure, useful in some pipelines but hides problems. Use sparingly.
--path= Runs a specific migration directory, handy for multi-tenant databases.

Order of Operations in the Deploy Script

For additive migrations (the expand phase), migrate before swapping the code, so the new code never meets the old schema:

  1. Build the new release directory
  2. php artisan migrate --force --isolated
  3. Swap the symlink to the new release
  4. php artisan config:cache, route:cache, event:cache
  5. Reload PHP-FPM and restart queue workers (php artisan queue:restart)
  6. Run the backfill command manually, when you are watching

For the contract phase (drops), do the opposite: deploy the code first, let it bake, then run the destructive migration in a separate release.

Multi-Tenant Databases

If you run a database per tenant, never loop over 500 tenants in a single synchronous deploy step. Dispatch one queued job per tenant, record success or failure per tenant in a tracking table, and make the job safe to retry. A single failing tenant should not block the release.

Step 7: A Rollback Plan That Actually Works

Reflex says php artisan migrate:rollback. In production, that is often the wrong move: rolling back a destructive migration cannot bring data back, and it may collide with code that is still live.

Decide in advance which of the three strategies applies:

  1. Roll back the migration when the change is purely additive and no data was written to the new structure yet. Safe and cheap.
  2. Roll forward with a fix migration when data already lives in the new structure. Write a new migration that corrects the problem. This is the right answer in most cases.
  3. Restore from backup when data has been lost or corrupted. Slow, painful, and the reason step 1 of the checklist exists.

Document the chosen strategy in the pull request description. During an incident, nobody wants to improvise.

Step 8: Test Migrations Before They Reach Production

A migration is code. Treat it like code.

In Continuous Integration

  • Run php artisan migrate --force on a clean database, then php artisan migrate:rollback --force, then migrate again. If any step fails, the build fails.
  • Keep a committed schema dump with php artisan schema:dump so new environments build fast and you have a reference for drift detection.
  • Write a test for the backfill command: seed representative rows, run the command, assert the resulting values and assert that running it twice changes nothing.

On a Production Clone

The only reliable way to know how long a migration takes is to run it against production-sized data. Restore last night’s backup to a scratch instance, time the migration, and record the duration in the pull request. A change that takes 4 seconds on 10,000 rows may take 25 minutes on 30 million.

Guard Against Schema Drift

Manual hotfixes on production databases happen. Add a scheduled check that compares the live schema with the schema your migrations produce, and alert when they diverge. Catching drift on a Tuesday afternoon is infinitely better than discovering it during a Friday deploy.

database server code

The Repeatable Checklist

Copy this into your pull request template:

  • Migration is additive only, or destructive changes are in a separate, later release
  • down() is written and tested locally
  • Output of migrate --pretend is attached to the PR
  • Estimated duration measured on a production-sized dataset
  • No data manipulation inside the migration file
  • Backfill lives in a dedicated, batched, resumable Artisan command
  • New columns are nullable or defaulted; constraints added after backfill
  • Indexes are named explicitly
  • Queue workers restarted after deploy
  • Backup verified within the last 24 hours
  • Rollback strategy chosen: rollback, roll forward or restore

Common Mistakes We Still See in 2026

  • Using Eloquent models inside migrations. The model changes over time; the migration must stay frozen. Use the query builder with explicit table names.
  • Calling migrate:fresh or migrate:refresh with production credentials loaded. Restrict these commands in non-local environments.
  • Editing a migration that has already run in production. It will never re-run there. Ship a new migration instead.
  • Seeding business data through seeders in production. Reference data belongs in an explicit, versioned, idempotent data migration or command.
  • Forgetting that dropColumn on older SQLite setups rebuilds the table. Test on the engine you actually deploy to, not only on SQLite in CI.

FAQ

Should I run Laravel migrations automatically on every deploy?

Yes for additive, fast migrations, using php artisan migrate --force --isolated in the pipeline. No for long-running or destructive ones: those should be executed deliberately, by a human, in a monitored window.

How do I preview a Laravel migration before running it in production?

Use php artisan migrate --pretend. Laravel converts every pending migration into raw SQL, prints it to the terminal and executes nothing. Combine it with php artisan migrate:status to confirm exactly which files are pending.

Can I put data updates inside a schema migration?

For a handful of rows, yes. For anything that touches a meaningful portion of a large table, no. Put it in a dedicated Artisan command so it can be batched, throttled, monitored, paused and resumed without blocking your deployment.

How do I add a NOT NULL column to a table that already has millions of rows?

Three steps across separate releases: add the column as nullable (or with a default), backfill it in batches, then alter it to NOT NULL once no null values remain. Never attempt all three in one migration.

What is the safest way to rename a column in production?

Do not rename it directly. Add the new column, dual write from the application, backfill the historical rows in batches, switch reads to the new column, stop writing the old one, and only then drop it in a later release.

Does migrate:rollback restore deleted data?

No. down() only reverses structure. If a column or table was dropped, the data is gone and only a backup can bring it back. That is why destructive migrations always require a verified, recent snapshot. cloudways.com has covered this at length.

How do I handle migrations across many tenant databases?

Dispatch one queued job per tenant rather than looping synchronously during the deploy. Track per-tenant status, make each job retryable and idempotent, and alert on tenants that remain behind the expected migration version.

Final Thoughts

A safe Laravel database migration in production is not about clever code. It is about sequencing: additive first, data second, destructive last, each in its own release, each with a measured duration and a written rollback plan. Once the team follows the same checklist on every pull request, migrations stop being the scary part of a deploy.

If you are running a Laravel application where downtime has a real cost, our team at Box Software can audit your migration workflow, your deploy pipeline and your database schema, then set up the guardrails that keep releases boring. Get in touch to discuss your project.