Guard every migration down(), or a rollback stops halfway

One unguarded down() dropped tables, stopped migrate:refresh and left the site answering 500. The rule we wrote afterwards is four lines long.

On 14 October 2026, migrate:refresh --seed on a shared development database stopped at the first down() that tried to drop a constraint that was not there. We fixed it. It stopped at a second one. Then a third and a fourth. Each time, the rollback had already dropped some tables and not others, and the site answered 500. Four files, one cause.

Why it happens

A developer database is older than the migrations that run against it. Somebody dropped an index by hand, or an earlier migration ran in a different order, or a package was installed later than its tables. A down() that assumes the object exists will fail on the first database where it does not. And because a rollback runs many down() methods in a row, the failure leaves the schema in a state that no up() was written to start from.

The rule: ask the schema first

Every down() must be safe on a database that never had the object. The shape of the guard is the same everywhere:

public function down(): void
{
    if (! Schema::hasIndex('things', 'things_code_unique')) {
        return;
    }

    Schema::table('things', function (Blueprint $table) {
        $table->dropUnique(['code']);
    });
}

Use Schema::hasIndex, Schema::hasColumn and Schema::hasTable. A try and catch inside the Schema::table closure does not work: the closure only queues the command, and the DROP runs after the closure has returned, outside your catch.

Three companion rules

  1. A seeder is safe to run twice. Key it on a natural key and use updateOrCreate or firstOrCreate, never a bare create.
  2. Test it the moment you change it, then commit it in the same step. A half-tested migration left uncommitted is what stops the refresh for everyone sharing the tree.
  3. Never test on the database named in .env. Use a scratch database.

The scratch test, in order

create a scratch database
migrate
migrate:rollback --step=1000     every down() must pass
migrate:refresh --seed           up, down, up and seed
drop the scratch database

For the older-database case, drop an index by hand after the first migrate and run the rollback again. It must still pass. And clear the cached configuration first: a cached config ignores the environment override and quietly runs against the real database.

Packages make it worse, and then better

Package migrations are published into the app or loaded from the package. If a package migration has an unguarded down(), the fix belongs in the package repository, followed by a republish. Patching only the app copy hides the fault until the next product installs the package. That is also why the package contract asks every install command to be safe to run twice and every uninstall to say what it keeps. The same files were fixed in the starter and in a second product, and the strip to a bare core had already rebuilt a fresh migration baseline for the starter.

What I would tell another small team

Test the rollback as often as you test the migration. Nobody notices an unguarded down() until the day they need it, and by then a shared database has paid for it.

Retrospective post. Written on 27 October 2026 from our project records and dated to the day the work happened.
Net Advice
Loading