DEPLOY.DRIFT.MISSING_IN_DATABASE — The migrations describe it and the database does not have it
- Category: safety
- Severity:
high— the one of the three classes that earns it - Level: 0
- Downtime class:
online— reporting a difference changes nothing and takes no lock - Stability: stable
- Suites: deploy
- Applies to: PostgreSQL, MySQL
What it reports
An object the migration state produces that is absent from the live database — one finding per object.
The expectation side is a real replay of the migration state into a throwaway database, so "the migrations describe it" means the migrations actually created it when run, not that a dump file says so. Both sides are canonicalized and read with the same request before anything is compared.
Why high, when the obvious version of this is already visible
Because the obvious version is the harmless one.
Usually the application was written against the object, reaches for it, and fails — loudly, in an error tracker, minutes after the deploy. That finding tells you nothing you did not already know.
The case that makes the check worth having is the other one: an object nothing reads yet. A column added for a feature that ships next week, a table for a job that is not scheduled, an index for a query that is still behind a flag. Nothing fails. The migration is recorded as run, or is not, and either way the schema quietly disagrees with the files for months — until the feature ships, at the worst possible moment, in production.
That is why the severity is high while the loud version is trivial: severity is set for the finding you would not otherwise have.
What to do about it
First, find out which of the two happened, because the fixes are different and this check cannot tell them apart:
SELECT migration, batch FROM migrations ORDER BY id DESC LIMIT 20;
- The migration is NOT in the table → it never ran. Run
migrateand the finding is gone. Worth asking why it was skipped: a deploy step that exited early, a filtered path, a branch that was merged without its migration. - The migration IS in the table → it ran and did not do what it claims. This is the worse case,
and the more interesting one. A migration wrapped in a condition that was false at the time, a
statement inside a transaction that was rolled back while the migration record was written outside
it, or a
downthat ran and was never followed by a freshup.
For the second case the fix is a new migration, never an edit to the old one: the old one is recorded as applied on every other environment, and editing it makes those environments silently diverge from the file that describes them.
Schema::table('orders', function (Blueprint $table): void {
if (! Schema::hasColumn('orders', 'fulfilled_at')) {
$table->timestamp('fulfilled_at')->nullable();
}
});
What it does not claim
- It cannot tell a migration that FAILED from one that was never run. Both leave exactly the same
absence in the schema. The
migrationstable is what answers that, and the section above is how. - It says nothing about when the object went missing. The comparison is of two states; there is no history in it.
- It is not a statement about object types it could not read. A type one side could not cover is
reported as a named blind spot and makes the run
undetermined— a quiet report and a clean one are different results, and the run says which it produced. - A rolled-back migration and a deliberately reverted feature look identical. If the object is genuinely not wanted, the fix is to remove it from the migration state rather than to add it to the database — and until that happens the finding is correct.
- It writes nothing and locks nothing. The database being examined is only ever read.