Profile    Mohammed Shiroz Status   Loading  
Logo
Share This
Back to blog
Filter by:
Tags
//Article title

Writing Laravel Migrations That Are Safe to Run Twice and Easy to Roll Back

About Post

The deploy runs php artisan migrate. The migration adds two columns and a unique index. The first column is created. The second column is created. The index fails, because of a duplicate value nobody knew was there.

You fix the data and run the migration again. It fails immediately: column already exists.

Welcome to one of MySQL's less famous features, and the reason "run it again" is not a recovery plan unless you designed your migrations for it.

Why a failed migration leaves a mess

In MySQL, schema changes like ALTER TABLE and CREATE INDEX cause an implicit commit. They can't be rolled back inside a transaction. So when a migration fails halfway, everything before the failure stays applied.

Meanwhile, Laravel only records a migration in the migrations table after it finishes successfully. The result is the worst of both worlds: the database is half-changed, and Laravel thinks the migration never ran. The next attempt replays the steps that already happened, and trips over them.

PostgreSQL supports transactional DDL, so a failed migration there can be rolled back cleanly. On MySQL and MariaDB, which plenty of Laravel apps run on, you have to plan for partial failure yourself. Here are the five habits that make that easy.

1. One concern per migration

The simplest protection is to make each migration small. A migration that adds one column can only fail in one place. A migration that adds three columns, renames a fourth, creates an index and updates data can leave you in a dozen different half-states.

Small migrations also roll back more precisely, and their names (add_due_date_to_invoices_table) tell you exactly what happened in a deploy log.

2. Guard the steps that can't be repeated

For changes that would fail if repeated, check before you act. The schema builder can tell you what exists:

return new class extends Migration
{
    public function up(): void
    {
        Schema::table('invoices', function (Blueprint $table) {
            if (! Schema::hasColumn('invoices', 'due_date')) {
                $table->date('due_date')->nullable()->after('issued_at');
            }
        });
    }

    public function down(): void
    {
        Schema::table('invoices', function (Blueprint $table) {
            if (Schema::hasColumn('invoices', 'due_date')) {
                $table->dropColumn('due_date');
            }
        });
    }
};

Now a retry after a partial failure skips what's already done and carries on. Schema::hasTable() and Schema::hasIndex() cover tables and indexes the same way.

One honest caveat: guards can hide drift. If due_date already exists but as a string instead of a date, the guard happily skips it. Use guards as a safety net for retries, not as a way to paper over databases that have wandered away from your migrations.

3. Write a real down(), and be honest when you can't

A down() method should undo exactly what up() did, in reverse order. If up() adds a column and then an index on it, down() drops the index and then the column.

Some migrations aren't truly reversible. If up() drops a column, down() can recreate the column, but not the data that was in it. Don't pretend otherwise. Either keep the data somewhere first, or make the irreversibility explicit:

public function down(): void
{
    throw new RuntimeException('Irreversible: legacy_notes data was removed.');
}

A loud failure on rollback is much better than a silent "rollback" that leaves you believing the data is back.

4. Keep data changes out of schema migrations

It's tempting to backfill the new column right inside the migration. Resist it, for three reasons:

  • Time. Updating a large table inside a deploy can hold locks and blow past your deploy timeout.
  • Models change. A migration that uses Invoice::query() runs today's model code. A year from now, someone runs migrate:fresh and the model has new casts, scopes or a renamed column, and an old migration breaks.
  • Retries. A data update that fails halfway needs to resume, not restart.

Put the backfill in its own Artisan command (or a queued job) that's safe to run any number of times:

public function handle(): void
{
    DB::table('invoices')
        ->whereNull('due_date')
        ->chunkById(500, function ($invoices) {
            foreach ($invoices as $invoice) {
                DB::table('invoices')->where('id', $invoice->id)->update([
                    'due_date' => Carbon::parse($invoice->issued_at)->addDays(30),
                ]);
            }
        });
}

The whereNull('due_date') filter is what makes it idempotent: run it twice and the second run finds nothing to do. Stop it halfway and the next run picks up where it left off. It uses the query builder, not the model, so it won't break when the model changes. And chunkById is the right choice when you're updating the same column you filter on.

The rule: schema migrations change the shape of the database; commands change the data. Each should be safe to run again after a failure.

5. Test the round trip in CI

Most broken down() methods are discovered at the worst possible moment: when you actually need them. Test them on every pull request instead:

php artisan migrate:fresh --force
php artisan migrate:rollback --force
php artisan migrate --force

After migrate:fresh, every migration is in the same batch, so migrate:rollback runs every down() method. Then migrate runs every up() again on top of the rolled-back schema. If any step fails, the build fails. Run this against the same database engine as production, not SQLite, or you'll test a different set of behaviours.

Two more checks worth knowing: php artisan migrate --pretend prints the SQL without running it, which is great for reviewing a risky migration, and nothing beats running new migrations against a recent copy of production data before the real deploy.

A word on rolling back in production

Here's my opinion: down() methods are essential in development and CI, and useful for a quick revert right after a deploy. But once real data has been written to the new structure, rolling back is often riskier than rolling forward with a new, fixing migration. Write good down() methods anyway. Just don't make them your only plan.

Checklist

  • One concern per migration.
  • Guards on steps that can't be repeated, with drift in mind.
  • A down() that mirrors up(), or an explicit "irreversible".
  • Data changes in idempotent commands, using the query builder.
  • CI runs migrate, rollback and migrate again on the production engine.

Which one has saved you, or would have? I suspect for most of us it's number four.

Comments (0)
Leave your review

Thanks for your valuable comments. Your comments has been updated and appreciate your getting in touch...

01. About Shiroz

Mohammed Shiroz

Hi, I'm Mohammed Shiroz, a software engineer and AI enthusiast from Sri Lanka who turns ideas into intelligent, real-world solutions. With over 9 years of hands-on experience, I currently lead real estate ERP development at Kate Group, a...

03.My Projects

04. Categories

Ready To order Your Project ?

Get in Touch
Close