GEN.L4.DOWN_MISSING — A migration that cannot be rolled back
- Category: safety
- Level: 4
- Confidence: deterministic
- Downtime class:
online - Stability: stable
- Suites: lint
- Applies to: PostgreSQL 18 and MySQL 8.4
Laravel rolls back by calling down(), and nothing else. A migration with no down() — or one whose
body runs nothing — makes migrate:rollback report success and change nothing at all.
The moment that matters is the moment a deploy is already going wrong, which is the worst possible time to discover it.
A check no SQL linter can make
Rollback is a framework concept expressed in PHP, so a tool that only reads SQL has nothing to
look at. And the verdict has to be read from the file, not from a run: a missing down() emits
no statements, an empty one emits no statements, and a down() that legitimately had nothing to
undo emits no statements either. At runtime all three are identical. Only the source tells them
apart.
Reading it off the parse the pre-scan already does keeps the check inside the sub-second single-file fast path, which is where a pre-commit hook lives.
Flagged
// The down() body, as this migration leaves it. Laravel rolls back by calling down()
// and nothing else, so `migrate:rollback` reports success and changes nothing. A down()
// that is absent entirely is the same outcome with one fewer clue.
// (nothing here)
Preferred
Schema::table('accounts', function (Blueprint $table) {
$table->dropColumn('locale');
});
When the change genuinely cannot be undone
Say so. A dropped column's data is gone, and a down() that pretended otherwise would be worse than
none:
public function down(): void
{
throw new RuntimeException('the dropped locale column cannot be restored');
}
An answer on record beats an absence, and it is the one thing a rollback must never do quietly — succeed without undoing anything.
Missing and empty are different mistakes
They share a consequence and not a cause: one was never written, the other was written and left
hollow. The finding names which of the two it found, because the fix differs — one is an omission,
the other usually a down() somebody meant to come back to.
On MySQL it weighs more, not less
Every MySQL DDL statement causes an implicit commit, so a failed up() leaves a partially
applied schema behind and no transaction rolls it back. down() is not a safety net there — it is
the only teardown there is, and it is yours to write. The finding says so on MySQL and stays
quiet about it on PostgreSQL, where the migrator really does wrap the migration in a transaction.
A run that never looked reports nothing
If the pre-scan never saw the file, the rule says nothing at all. "Nobody looked" is not "there is none", and inventing a finding out of a blind spot is the failure this package is built against.
Sources
- Laravel migrations — rolling back — Laravel 13
The fix material this rule carries
A finding from this rule carries a payload whose strategy is none: this rule has looked,
and there is no safe standard sequence. That is a conclusion rather than an omission — a
finding with no payload at all says only that nobody wrote one.
The payload is material for you or an agent to apply. SQLens writes no migration and runs no DDL. See the remediation payload for every field, the placeholder semantics, and the version rules a consumer has to follow.