Skip to main content

Post-deploy verification

migrate --force exiting 0 means every migration ran. It does not mean every object those migrations created is usable.

A CREATE INDEX CONCURRENTLY that failed leaves an INVALID index behind and does not fail the migration. A constraint added NOT VALID whose VALIDATE never followed is a promise the database is not keeping. An expand/contract rename that stopped halfway leaves users_old sitting there forever. All three are silent in migrate's own output, all three survive indefinitely, and none of them is visible to anything else in your pipeline.

php artisan sqlens:postdeploy

That is the whole command. It reads catalogs, it takes no lock, it writes nothing, and it ends.

What it checks

CheckEngineAnswers
DEPLOY.LEGACY.INVALID_INDEXPostgreSQLan index the catalog marks indisvalid = false — a concurrent build that failed
DEPLOY.LEGACY.INVALID_INDEXMySQLnot applicable, out loud. InnoDB has no invalid-index state, and silence would read as "nothing found"
DEPLOY.LEGACY.CONSTRAINT_NOT_VALIDATEDPostgreSQLa constraint added NOT VALID whose VALIDATE never ran
DEPLOY.LEGACY.CONSTRAINT_NOT_ENFORCEDMySQLa CHECK constraint with ENFORCED = NO — in the catalog, in SHOW CREATE TABLE, and admitting every row it claims to refuse
DEPLOY.LEGACY.ORPHAN_TRANSITION_OBJECTPostgreSQLan object whose NAME says an unfinished migration left it
DEPLOY.LEGACY.OSC_ARTIFACTMySQLwhat gh-ost, pt-online-schema-change or a copy rebuild left behind
DEPLOY.RUN.TIME_BUDGET_EXCEEDEDboththe run's own cost, when it broke the budget you set

Each links to its own page from the finding itself, so you never have to come back here to find out what one means.

Two of them are undetermined by design, and that is worth understanding before you read your first report: a name is not evidence, and an online-schema-change run still in flight looks exactly like one that died. See understanding undetermined, and --expect-shadow below for the second reading that settles them.

What it is NOT

It is not monitoring. Not a watcher, not a daemon, not an APM replacement. There is no --watch, no scheduler entry and no history — and that is a property of the code rather than a promise in this paragraph: PostdeployVerifier has no loop and keeps nothing between calls.

If you want to know how your application is behaving over time — queries getting slower, errors appearing, queues backing up — that is Laravel Pulse, Sentry, or whichever APM you already run. They watch a running system. This reads a schema, once, at the moment a deploy finished, and then it is gone.

The distinction matters because the two are easy to conflate and impossible to substitute. An APM will never tell you an index is INVALID; this will never tell you a query got slow.

It is not a gate. The deploy has already happened. A non-zero exit here is a report about work already done, not a refusal to start anything — the gate is sqlens:predeploy, and it runs before the migration.

Exit codes

The same four codes every SQLens command uses, so a pipeline never has to learn a second contract:

CodeMeaning here
0every check ran and found nothing
1something was found, or the run broke its time budget
2misconfiguration — no connection, an unknown --format, an unreadable exclude file. Nothing was learned about the database
3a check could not answer, and --allow-undetermined was not passed

Code 3 is the one worth a decision. A check that could not run has not established that anything is fine, so it is reported rather than swallowed — and whether that should stop your pipeline is your call. --allow-undetermined says "record it and carry on"; when it opens, the report says so in its own field, so a waived green and an earned one are never the same document.

Proving the schema matches your migrations

Everything above searches the database for wreckage. The opposite problem — something in the database that no migration describes at all — is invisible to a catalog reading, because the catalog holds it and it looks perfectly healthy. A hotfix applied straight to production is exactly that shape, and the next migrate:fresh on a rebuilt environment silently loses it.

php artisan sqlens:postdeploy --expect-shadow

This replays your migrations into a throwaway shadow database and compares the result against the live schema. It is the same comparison sqlens:drift makes, through the same code, so the two commands cannot disagree — differences arrive as ordinary findings under DEPLOY.DRIFT.*, and your sqlens-drift-excludes.json is honored here too.

It also settles the two undetermined checks: when the comparison confirms that no migration describes an object whose name says leftover, the two readings agree from different directions and the finding is raised to fail.

It is off by default, because building the expectation CREATES a database and therefore falls under the production guard. The plain aftercare stays guard-free and cheap enough to run every time.

And a run without it says so — on stderr and in the report's run.expectation block. A report holding no drift findings would otherwise say the schema matches and nobody looked with exactly the same silence.

Privileges

Least privilege is a promise this package keeps rather than advertises, so it is worth being exact about what each half needs.

On the database you deployed to — reading only.

  • CONNECT to the database and USAGE on the schemas you want read.
  • SELECT on the system catalogs (PostgreSQL: pg_catalog; MySQL: information_schema). On PostgreSQL that is public by default; on MySQL information_schema is filtered by privilege without a word, so an under-privileged role sees a shorter list rather than an error. That is why a failed read here is reported as undetermined rather than as "nothing found".
  • No SELECT on your tables. Row contents are never read and never appear in a finding.
  • No write privilege of any kind. Not INSERT, not CREATE, not ALTER. The debt ledger is a file in your repository, not a table.

On the shadow host — only with --expect-shadow. That half creates a database and drops it again, so it needs CREATEDB (PostgreSQL) or CREATE/DROP on a database-name prefix it owns (MySQL). Point it at a scratch server rather than granting that on production: the guard will refuse a production connection anyway, and the guard is not the reason to keep it separate.

The full list, with the four things SQLens never asks for, is on deploy gate privileges.

Where it goes in a deploy

Immediately after migrate --force, in the same chain — the deploy recipe is the runnable version with the shell options that make it real.

sqlens:drift does not belong in that chain. Its expectation side replays your whole migration history, which is dominated by how much history you have rather than by anything this package does; it belongs in a CI or cron-adjacent run outside the deploy window. If you want the comparison inside a deploy, that is --expect-shadow above.

Its own cost is on the record

The run holds itself to sqlens.deploy.postdeploy.budget_ms — five seconds by default, milliseconds like every other budget here. Breaking it produces DEPLOY.RUN.TIME_BUDGET_EXCEEDED with the measured duration and the per-check split, and the run still finishes.

That is not vanity. A step that visibly delays a deploy gets configured away in the first sprint, and a step nobody runs has helped nobody — so the number is held by a test rather than by an intention.