Skip to main content

sqlens:drift — what the database holds against what the migrations describe

Your migrations describe a schema. Your database has one. sqlens:drift compares them.

php artisan sqlens:drift

The live side is the catalog of the connection you name, read through the sealed read-only session. The expectation side is a replay: the migration state applied for real into a throwaway database, whose catalog is then read with the same request.

A replay rather than a dump file, and the difference is not academic. A dump is a claim about what the migrations produce, made when somebody last regenerated it. A replay is the migrations themselves, run by the framework that will run them in production — and the two disagree exactly when it matters most: after a migration was edited, or added, and the dump was not refreshed.

Because the expectation side creates and drops a database, it runs behind the production guard and nowhere else.

It reports by default, and that decision is why the feature survives

The first run on a database that has been in production for years finds a decade of hand-made objects. A gate that turns red there is not adopted — it is switched off, and after that nothing is reported at all, which is strictly worse than a green exit somebody read.

So the shipped mode is report: it prints everything and exits clean.

mode: report
unexpected_in_database: 12 — exists in the database and in no migration
missing_in_database: 0 — the migrations describe it and the database does not have it
divergent: 3 — both sides have it and describe it differently
undetermined: 0
excluded: 0
compared: collation, column, constraint, index, table, view — and nothing else

The last line is the one that gives the others a meaning. undetermined says what could not be read; compared says what was never in the reading. The two catalog readers do not cover the same ground — nine object types on PostgreSQL, six on MySQL — and neither reads a stored routine, so an empty finding list is "nothing differs among these types" rather than "nothing differs".

That green exit is only defensible because the mode is printed with the findings. A run that exited clean without saying which mode produced it would be the silent green this package refuses.

The three ways two schemas disagree

Three and not two, because "different" is not one fact — the three have different causes and different remedies.

FindingMeansUsual cause
DEPLOY.DRIFT.UNEXPECTED_IN_DATABASElive has it, no migration describes ita hotfix applied straight to production
DEPLOY.DRIFT.MISSING_IN_DATABASEthe migrations produce it, the database does not have ita migration that failed, was skipped, or was rolled back
DEPLOY.DRIFT.DIVERGENTboth sides have it, described differentlyan edited migration, or a hand-altered column

A fourth id, DEPLOY.DRIFT.UNCOMPARED, is not a way they disagree — it is the statement that a slice of the schema was never compared, because one side could not read that object type. It is the only one of the four that can be true while the other three come back empty and every line of the report is accurate.

The path from reporting to blocking

Three steps, in this order. Skipping the middle one is why drift gates get abandoned.

1. Report, and read what comes back

Run it. Expect a lot on a grown database, and expect most of it to be real.

2. Accept what you meant to keep

The reporting view somebody built by hand, the index a foreign system owns, the column that grew there historically — these are decisions, not drift. Record them once:

php artisan sqlens:drift --update-excludes

That writes every difference this run found into sqlens-drift-excludes.json, one entry per line, sorted by identity so a diff stays readable:

{
"schema_version": 1,
"entries": [
{
"type": "view",
"name": "public.legacy_report",
"attribute": null,
"reason": "unexplained: replace this with why the difference is deliberate, or delete the entry"
}
]
}

Every entry needs a reason, and the generated placeholder is refused by name. That is deliberate: an exclusion that does not say why it exists cannot be reviewed, and six months from now nobody can tell a decision from a finding somebody silenced on a Friday. A file straight out of --update-excludes therefore does not run until somebody edits it: replace each placeholder, or delete the line.

attribute narrows an exclusion to one part of a divergence — collation, default. Left null it accepts the object's disagreement whole, which also hides the day its type changes. Narrow it where you can.

The file lives in your repository, never in a database table. A decision about a schema belongs where decisions are reviewed: in a pull request, with an author and a date, and with its removal as visible as its addition. Point somewhere else with --exclude-file= or sqlens.deploy.drift.exclude_file.

An entry that matches nothing ends the run. Excludes nobody prunes grow into a ceiling under which real drift disappears, and the growth is invisible — forty stale lines look exactly like four that are still true. When a difference is resolved, delete its line.

The withdrawn differences stay counted in the summary (excluded: 4). An exclude file is not a blindfold: a report showing three findings where the comparison found forty would be accurate in every line and misleading as a whole.

3. Only then, block

php artisan sqlens:drift --fail-on-drift

or, once, in config/sqlens.php:

'deploy' => [
'drift' => [
'mode' => 'gate',
],
],

Anything that is not literally gate is report, a typo included — erring toward blocking on a value nobody can read would put a project into a mode it never asked for.

What an exclude file can never hide

A blind spot — an object type one side could not read — travels through the exclude file untouched, and the run stays inconclusive however many exclusions it carries.

That is the one rule this feature would be worth nothing without. A comparison that could not read indexes finds no index drift, prints zeroes and exits clean; every line of that report is accurate and the sentence a reader takes from it is not. Letting an exclusion silence it would turn "we cannot look here" into a clean bill of health with a reason attached.

Options

OptionDoes
--connection=the connection to compare; the application default when omitted
--format=console, json, github, sarif or agent — the report goes to STDOUT, everything else to STDERR
--fail-on-driftexit non-zero when the two sides disagree
--allow-undeterminedexit clean when the only thing blocking is what could not be read
--exclude-file=where the accepted differences are recorded
--update-excludeswrite this run's differences into that file, then stop
--forceskip the confirmation the production guard asks for

--update-excludes is housekeeping and ends the run: it writes, says what it wrote, and exits clean. It does not also judge — a command that gated on the very differences it had just written could never fail on the run that changed anything.

It carries NO time budget, and that is a decision rather than an oversight

sqlens:predeploy and sqlens:postdeploy both hold themselves to five seconds, enforced by a test and reported as a finding when they break it. sqlens:drift has no such number, deliberately.

The reason is that any number written here would be invented. Drift builds its expectation by replaying your migrations into a shadow database, and that is dominated by how much migration history you have: a project with 40 migrations and one with 4,000 are not the same measurement, and neither figure is about SQLens. An invented budget goes red on projects that are behaving perfectly, and the first fix anybody reaches for is to delete the check — so a made-up number is worse than none.

What replaces it is a statement about where the command belongs:

sqlens:drift is not a step in a deploy script's hot path. It is a CI or cron-adjacent run, outside the deploy window, where taking a minute costs nobody anything.

Run it on a schedule, or on a branch, or before a release — not between migrate --force and the moment traffic reaches the new release.

If you do want the comparison inside a deploy, that is sqlens:postdeploy --expect-shadow — off by default, behind the production guard, and deliberately outside that command's budget for exactly the reason above: its cost belongs to your migration history, not to the aftercare that runs unconditionally.

What it does not do

  • It never writes to the database being examined. Not the live one; the throwaway database is the only thing it creates, and it drops it in a finally.
  • It does not tell you which side is right. That a table exists in the database and in no migration is a fact; whether to write the migration or drop the table is a decision.
  • It compares the canonical form of each side, so a difference that canonicalization erases is invisible here by design — and one it fails to erase is a finding the tool caused itself.
  • Roles, grants and server settings are not compared. They are real drift and they matter, and they are the audit and security suites' subject.