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
- A seeder is safe to run twice. Key it on a natural key and use
updateOrCreateorfirstOrCreate, never a barecreate. - 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.
- 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.