Skip to main content

CAP.L0.DOWN_NOT_INVERTIBLE — down() did not restore what up() changed

  • Category: safety
  • Level: 0
  • Stability: stable
  • Suites: lint

The roundtrip replayed up, then down, then up again inside a throwaway database, and the second up failed. That is a proof, not a guess: the migration cannot be applied to the schema its own down() left behind, so down() is not a real inverse.

This verdict is out of reach for any tool that only reads SQL. Whether down() inverts is not a property of its text — it is a property of the state it leaves, and the only way to learn it is to undo and redo against a database you are allowed to break. That is what --roundtrip is for, and it runs in shadow mode only, because a down() is allowed to drop things.

Three different failures can happen during a roundtrip, and they are never conflated. If the first up fails, the migration itself is broken and down() was never reached — that is CAP.L0.MIGRATE_ERROR. If down fails, the reverse leg raised an error and what it would have left behind is unknown — that is CAP.L0.DOWN_FAILED, and the roundtrip stops there rather than judging a second up against a state nobody planned. This finding is the third case: both legs ran, and the result is a schema the migration cannot be applied to again.

The finding carries the driver's own message, with connection credentials redacted and absolute paths relativized, so the same failure reads identically on every machine.

False positives

A migration that is deliberately irreversible is not a defect. A data migration whose down() cannot restore what it consumed is a legitimate design, and this rule will still flag it, because from the outside the two look the same. Silence it with a suppression annotation on the migration class: that records the decision in the code, where the next reader will find it, instead of weakening the rule for every other migration.

Flagged

public function up(): void
{
Schema::create('invoices', function (Blueprint $table): void {
$table->id();
});
}

public function down(): void
{
// Not an inverse: the table survives, so applying `up` a second
// time fails on a table that already exists. Nothing about this
// is visible in the SQL — only the replay finds it.
Schema::dropIfExists('invoice_lines');
}

Preferred

public function up(): void
{
Schema::create('invoices', function (Blueprint $table): void {
$table->id();
});
}

public function down(): void
{
Schema::dropIfExists('invoices');
}