DEPLOY.DRIFT.UNEXPECTED_IN_DATABASE — The database holds something no migration describes
- Category: safety
- Severity:
medium— deliberately, and the reasoning is at the end of this page - Level: 0
- Downtime class:
online— the finding is a statement about a difference; reporting it changes nothing and takes no lock - Stability: stable
- Suites: deploy
- Applies to: PostgreSQL, MySQL
What it reports
An object that exists in the live database and in no migration — one finding per object.
The comparison has two sides. The live side is the catalog of the database being examined, read read-only through the sealed session. The expectation side is the catalog of a throwaway database into which the migration state was replayed — not a dump file, which is a claim about what the migrations produce made whenever somebody last regenerated it, but the migrations themselves.
Both sides are reduced to their canonical form before anything is compared, and both are read with the same request — the same schemas, the same object types, the same table prefix. A difference that survives that is a difference in the database, not in how the two readings were taken.
Why this class is the reason the comparison exists
It is the only one of the three that no other tool notices.
A missing object announces itself: the application reaches for it and fails. A divergent one usually shows up as a query behaving oddly. An unexpected one is silent — it works. Somebody added an index during an incident, at 3 a.m., straight against production, and it did exactly what it was supposed to do. Every migration since has run green.
The deadline is the next rebuild. migrate:fresh on a new environment, a restored staging copy, a
new region — the schema is rebuilt from the files, and the object simply is not there. The
performance problem it solved comes back, in an environment nobody associates with that incident.
What to do about it
Decide first, then act. The decision is whether the object is wanted.
It is wanted → write the migration that creates it, and make it safe to apply where the object already exists:
Schema::table('orders', function (Blueprint $table): void {
// The index already exists on the instance the hotfix touched, and does not exist
// anywhere else. Guarded so both worlds converge on the same schema.
if (! Schema::hasIndex('orders', 'orders_customer_id_created_at_index')) {
$table->index(['customer_id', 'created_at']);
}
});
That is the whole fix: the object stops being unexpected because a file now describes it.
It is not wanted → drop it. An object nobody intends to keep still costs writes, still lengthens the next rewrite, and will still surprise the person who rebuilds the environment.
Either way the report goes quiet on the next run, because the two sides now agree.
A third answer is legitimate and is not "ignore it": an object that genuinely belongs to something else — an extension, a monitoring tool, a replication system — is not yours to migrate. It belongs in the exclude file, where it is a recorded decision with a name on it rather than a finding somebody scrolled past.
Why medium, and not higher
Because of where this check runs first: a database that has been in production for years.
The first drift run on a grown environment is the one that decides whether the feature is used at all. A decade of hand-made indexes is ordinary there, not alarming, and a gate that shouts about all of them at once is a gate somebody switches off — after which none of the three classes is ever read again. That failure mode is well documented in this space, and it is the reason the drift comparison reports by default and blocks only when a project asks it to.
What makes a specific finding urgent is the next migrate:fresh on a rebuilt environment, and this
check does not rank on that: it cannot know your deploy pipeline. Severity is the floor here, not the
whole judgment.
What it does not claim
- It does not know who created the object, or when. The comparison is between two states, not a
history. The database records no author for a
CREATE INDEX, and inventing one would be worse than saying nothing. - It is not a statement about object types it could not read. A type one side could not cover —
a missing privilege, a managed instance that hides something — is reported as a named blind spot
and makes the run
undetermined. A quiet report is not the same as a clean one, and the run says which it is. - It writes nothing and locks nothing. Both sides are catalog reads. The throwaway database is created and dropped behind the production guard; the database being examined is only ever read.
- It cannot see a difference the canonical form erases — and that is deliberate. Two spellings of one definition are not drift, and a comparison that reported them would produce findings the tool caused itself.