GEN.L1.DOWN_DROPS_WHAT_UP_FOUND — A rollback that drops what up() may have found already
- Category: safety
- Level: 1
- Confidence: heuristic
- Downtime class:
online - Stability: preview
- Suites: lint
- Applies to: PostgreSQL 18 and MySQL 8.4
A guard such as Schema::hasTable() lets up() run on a database that already holds the table, and
nothing carries that answer over to down(). On an empty database the pair is symmetric. On one an
earlier install already filled, up() builds nothing, and the rollback deletes rows the migration
never wrote.
Flagged
// up() creates the table only where it is missing:
if (! Schema::hasTable('ledger_markers')) {
Schema::create('ledger_markers', fn (Blueprint $table) => $table->id());
}
// down(), in the same migration, drops it whatever up() found:
Schema::dropIfExists('ledger_markers');
Preferred
// In a migration annotated #[SqlensAllowDestructive]. up() as before; down() refuses while the table holds rows:
if (Schema::hasTable('ledger_markers') && DB::table('ledger_markers')->exists()) {
throw new RuntimeException('ledger_markers holds rows an earlier install wrote; export them and drop the table by hand.');
}
Schema::dropIfExists('ledger_markers');
The refusal names what it would delete, so the person running the rollback learns why it stopped, and an empty table still goes.
What it compares
The rule reads two things the migration file says and puts them beside the rollback:
- the tables and columns
up()asks about in a guard:Schema::hasTable(),Schema::hasColumn()andSchema::hasColumns(), called on the facade or on a schema builder, where the answer decides a branch, also through a variable or the return of a helper method; - whether
down(), or a method it calls, can throw.
| The rollback… | is reported when up()… |
|---|---|
| drops or truncates a table | asks whether the table exists and creates it |
| drops a column | asks whether the column exists |
A down() that can throw is not reported: a rollback that refuses while there is data is the remedy.
A table up() asks about but never creates is
GEN.L1.DOWN_MORE_DESTRUCTIVE's finding, and this rule says nothing
about it.
Why the captured statements cannot show it
Under pretend mode the guard is answered with nothing, so the CREATE inside it is captured as if it
always ran, and a rollback that drops what up() created is the ordinary one. The difference is only
in the file, which is why the pre-scan reads it there.
In pretend mode a migration with such a guard is reported by CAP.PRESCAN.INTROSPECTION_GUARD and not captured, so this rule judges it in shadow mode, where the guard gets a real answer.
What it deliberately does NOT see
- A
throwthat guards something else. Everythrowindown()counts as a refusal, because the parse cannot tell one from another. That is why the confidence isheuristic. - A name the parse cannot read.
Schema::hasTable($this->table)asks about a table whose name is known only at run time, and a guessed name would point the finding at the wrong one. - Several columns in one drop. Laravel's
$table->dropColumn(['a', 'b'])emits one statement, and its classification names the first column.
When nothing can be lost
A column derived from the others holds nothing that cannot be rebuilt, so dropping it loses nothing. Say so on the migration, with the rule id and the reason:
return new #[SqlensIgnore(
rules: ['GEN.L1.DOWN_DROPS_WHAT_UP_FOUND'],
reason: 'active_identity is rebuilt from the other columns.',
)] class extends Migration
{
// ...
};
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.