Skip to main content

GEN.L1.DOWN_MORE_DESTRUCTIVE — A rollback that destroys more than the migration built

  • Category: safety
  • Level: 1
  • Confidence: deterministic
  • Downtime class: online
  • Stability: stable
  • Suites: lint
  • Applies to: PostgreSQL 18 and MySQL 8.4

Laravel rolls back by calling down(), and infers nothing from up(). Whatever down() contains is what a rollback runs — symmetric or not, and nothing checks which.

Flagged

// up() adds a column:
Schema::table('orders', fn (Blueprint $table) => $table->string('status')->nullable());
// down(), in the same migration, drops the whole table:
Schema::dropIfExists('orders');

up() added a column. down() drops the table. Both halves read fine on their own, and the file passes every gate the project has — because nothing runs down() until the day a deploy is already going wrong. Then the way out deletes every order in the system.

Preferred

// down() undoes exactly what up() did, and nothing beside it:
Schema::table('orders', fn (Blueprint $table) => $table->dropColumn('status'));

If part of the change genuinely cannot be undone, throw from down() with that reason rather than destroying something adjacent to make the schema look right — see GEN.L4.DOWN_MISSING.

What counts as unaccounted for

A rollback statement is unaccounted for when up() gives no reason for it:

The rollback…is reported unless up()
drops a tablecreated that table
truncates a tablecreated that table
drops a column, index or constrainttouched that table at all

TRUNCATE sits beside DROP because the two differ in what is left standing, not in what is lost: an empty table and no table are the same answer to the user whose rows are gone.

A statement naming no single table — or two, as a foreign key does — is not placed on either side. A finding has to be able to name what it is about, and picking a side would be a guess.

What it deliberately does NOT see

A down() that drops a different column of a table up() did touch. The bar for members is "up() touched this table", not "up() created this exact object", and that is a false-negative trade made on purpose:

  • Laravel's $table->dropColumn(['a', 'b']) emits one statement with two clauses, of which the classification names the first;
  • on PostgreSQL an added column is not classified as an add-column at all, so "did up() add exactly this column?" is not a question both engines answer the same way.

Asking the finer question anyway would flag the commonest symmetric migration in existence on one engine and not the other. What the rule loses by asking the coarser one is the case above; what it would lose by asking the finer one is its users.

Why level 1 and not 4

GEN.L4.DOWN_MISSING is about a rollback path that does not exist — nothing is destroyed, the way back is simply absent, and that is the deploy-window question level 4 asks. This one is destruction: a DROP TABLE nobody asked for, in a method that will be run under pressure. It belongs in the destructive band, and it fires as early as level 1 because that is the level a project turns on when it wants to be told before something is deleted.

Why the finding says online

The downtime class answers what applying this migration costs, and applying it never runs down(). A class derived from the rollback's own DROP TABLE would tell a deploy script that the migration blocks when it does not — the exact decision the field exists to get right. The rollback's cost is real, and it is in the finding's text where a human reads it.

On MySQL the damage is not even transactional

Every MySQL DDL statement causes an implicit commit, so a rollback that fails halfway through leaves what it already destroyed destroyed, and nothing undoes it. On PostgreSQL the same down() runs inside the migrator's transaction, where a failure at least takes back the part that ran. The finding says so on MySQL and stays quiet about it on PostgreSQL.

Reading a rollback is never running one

The statements are captured under Laravel's pretend mode — in every capture mode, shadow included. A lint run must not execute a rollback: down() is the one migration method whose job is to destroy things, and what the rule needs is the SQL it would emit.

What it reports when it cannot tell

Never a silent pass. Three separate answers, each with its own reason:

SituationReported as
the rollback leg could not be canonicalizedundetermineduncanonicalizable_statement
an up() statement was captured but not classifiedundeterminedup_state_not_capturable
a down() statement was captured but not classifiedundetermineddown_state_not_capturable

The up() case is checked first: what up() created is the baseline everything else is measured against, and reading a missing classification as "up() created nothing" would turn the most ordinary migration into a finding.

If nothing looked at the rollback at all, the rule says nothing. "Nobody looked" is not "the rollback destroys nothing".

Sources

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.