The deploy recipe: wire the gate so a failure actually stops the migration
A gate that reports and does not stop is a warning, and warnings get read for about two weeks. This
page is the wiring that turns sqlens:predeploy into something that actually holds a deploy back.
The chain
Three commands around the migration, in this order:
set -euo pipefail
php artisan sqlens:predeploy --format=json | tee storage/logs/sqlens-predeploy.json
php artisan migrate --force
php artisan sqlens:postdeploy --format=json | tee storage/logs/sqlens-postdeploy.json
That is the whole recipe. The package ships no script — there is nothing to install and nothing of ours running in your deploy. This block is the source of truth for the example, and a test in this repository extracts it from this page and runs it, so the recipe cannot drift away from what is proven.
The three shell options, and why each one is load-bearing
set -euo pipefail is not boilerplate here. Drop any one of the three and the chain still looks like
it works:
| Option | What it does | What breaks without it |
|---|---|---|
-e | stop at the first command that exits non-zero | migrate --force runs anyway. The gate reports, the deploy proceeds, and the report is read afterwards — if at all. |
-o pipefail | a pipeline fails when any stage fails, not just the last | predeploy | tee exits with tee's status. tee almost always succeeds, so the gate's verdict is discarded by the pipe that was only meant to keep a copy of it. |
-u | an unset variable is an error | A typo in a path variable expands to nothing, and a redirect writes somewhere else entirely. |
The second row is the one worth reading twice. Adding | tee to keep the report is the natural next
step for anybody wiring this up, and on a shell without pipefail it silently disables the gate.
What the exit codes mean to the script
The chain does not interpret them — set -e does. But the number that reaches your platform is the
gate's, and it is worth being able to read:
| Code | Meaning |
|---|---|
0 | clean: run the migration |
1 | findings above the gate — a fact about the database |
2 | misconfiguration: the run never happened, so nothing was established |
3 | undetermined under fail-closed: the gate could not answer |
A 3 stops the deploy on purpose. The database was unreachable, a privilege was missing, or the
budget ran out — and the migrate --force two lines later would have failed on the same database
anyway. Failing at the gate is the cheaper of the two, and it is the only one that leaves a report
behind saying what could not be read.
Measured, not assumed: with set -e, a predeploy exiting 3 leaves the script at 3 and
migrate is never invoked. That is the property the test in this repository asserts — by
recording which commands ran, not only by reading the final code, because a script that exits
non-zero after migrating would pass the weaker check.
When you deliberately want to proceed
php artisan sqlens:predeploy --format=json --allow-undetermined | tee storage/logs/sqlens-predeploy.json
This opens exactly one door: a run whose only blockers could not answer proceeds. A real finding still stops the deploy, because that is a fact about the database rather than about the gate's reach.
It is an emergency exit, not a recommendation — and it leaves a trace. The report's run header
carries undetermined_waiver: true, so a green that was waved through can never be mistaken later
for one that was earned. See the predeploy permissions page for the
project-wide equivalent and the full three-valued table.
Post-deploy is not a gate
The third command runs after the migration, so nothing is blocked by it: the deploy has already
happened. Its non-zero exit is a report about work already done — an index left INVALID by a failed
concurrent build, a constraint added NOT VALID whose VALIDATE never followed. Both are silent in
migrate's own output and both survive indefinitely.
Whether that should fail your deploy pipeline is your call and belongs in the recipe rather than in the command: some teams want the red build, others want the finding recorded and the release to stand. The exit code is the same contract either way, so a pipeline never has to learn a second one.
--expect-shadow: proving the schema matches, rather than only looking for wreckage
The post-deploy command reads catalogs. It finds what a failed migration left behind — an INVALID
index, an unvalidated constraint, an object whose name says leftover — and it is cheap enough to run
on every deploy because it creates nothing, locks nothing and needs no guard.
What it cannot see that way is the opposite problem: something in your database that no migration
describes at all. A hotfix applied straight to production is invisible to a catalog reading,
because the catalog holds it and looks perfectly healthy. The next migrate:fresh on a rebuilt
environment silently loses it.
php artisan sqlens:postdeploy --expect-shadow
That replays your migrations into a throwaway shadow database and compares the result against the
live schema — 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 the same
sqlens-drift-excludes.json you use with sqlens:drift is honored here: a difference your project
has accepted does not come back as a failure because it arrived through the other command.
It also settles a question the catalog reading has to leave open. A table called users_old might be
an abandoned rename or an archive somebody queries every quarter, so it is reported as
undetermined. When the comparison confirms that no migration describes it, the two readings agree
and the finding is raised to fail.
It is off by default, deliberately. Building the expectation creates a database, which puts the run behind the production guard — so the plain aftercare stays guard-free and a deploy recipe can run it unconditionally, while the proof is something you ask for.
And a run without it says so. Both on stderr and in the report's run.expectation block:
"expectation": { "requested": false, "compared": false, "note": "…the schema was NOT compared…" }
Without that field, a report holding no drift findings would say the schema matches and nobody looked with exactly the same silence.
Two things to set up once, before any of this helps
The gate runs as a least-privilege role, not as the migration user. sqlens:predeploy reads
catalog and state views and takes no locks of its own; it needs nothing that can change your schema.
On PostgreSQL that is the pg_monitor class of privileges, and the point of using it is not tidiness
— a gate holding DDL rights is a gate that could do the damage it exists to prevent, on the one
connection that runs unattended. The role setup is on the
predeploy permissions page.
Budget the gate in single-digit seconds, and treat anything else as a defect. It is one catalog
read plus the pending migrations, and it carries its own deadline — the number to watch is not how
long it takes but whether it answers undetermined because the deadline hit. That answer is
deliberate rather than a failure, and it is the reason the command never hangs on a busy database.
SQLens is not a deploy runner
This page is a recipe, not a tool. Nothing here runs your deployment, and nothing SQLens ships
ever will: sqlens:predeploy answers a question and exits, and every line around it is your
platform's to execute. There is no orchestrator, no rollback command, no "deploy" verb — and there
is a test that fails the build if one appears.
That is a boundary rather than a gap. A tool that both judges a deploy and performs it has to decide what to do about its own verdict, and the answer to that is a policy question belonging to the team whose database it is. So it stops at the verdict, and the exit code is the whole interface.
On Laravel Forge
Where the gate goes depends on which deployment strategy the site uses, and the difference is not cosmetic.
Zero-downtime sites get three injected macros — $CREATE_RELEASE(), $ACTIVATE_RELEASE(),
$RESTART_QUEUES() — and Forge places cd $FORGE_RELEASE_DIRECTORY after the first one. The gate
belongs after that cd and before $ACTIVATE_RELEASE():
$CREATE_RELEASE()
cd $FORGE_RELEASE_DIRECTORY
$FORGE_COMPOSER install --no-interaction --prefer-dist --optimize-autoloader --no-dev
# The gate. After the cd, because it needs the NEW code to see the pending migrations.
$FORGE_PHP artisan sqlens:predeploy
$FORGE_PHP artisan migrate --force
$ACTIVATE_RELEASE()
$RESTART_QUEUES()
$FORGE_PHP artisan sqlens:postdeploy
Putting the gate after $ACTIVATE_RELEASE() would defeat it: the site is already pointed at the
release the gate was meant to hold back. Forge's own promise for this strategy —
"If any step in the deployment process fails, your site will continue to use the previous release"
— only holds for steps that run before activation.
Standard sites have no release directory, so the gate sits between the code update and the migration. Forge names the trade-off itself: this strategy "does come with the risk of your site going down if a deployment step fails midway through the deployment process". That is an argument for the gate rather than against it — the earlier a deploy stops, the less of it has half-happened.
⏱️ Ten minutes, shared
Deployments are limited to 10 minutes. If a deployment takes longer, it will fail automatically.
That budget covers composer install, asset builds, migrations and the gate. It is why
sqlens:predeploy carries its own deadline and answers undetermined rather than hanging: a check
that ran out of the platform's clock would fail your deploy with a timeout that names Forge instead
of naming what could not be read.
On Envoyer
Custom hooks are positioned relative to four first-party steps — Clone New Release, Install Composer Dependencies, Activate New Release, Purge Old Releases. The gate goes on a hook before Activate New Release, for the reason above.
Envoyer states the contract this recipe depends on outright: a deployment hook that exits with a non-zero status code stops the whole deployment, like any other step in it. It is the only one of the two platforms that says so in a sentence.
⚠️ What neither platform promises — and why the recipe sets its own shell options
Both documents describe what happens when the script or hook exits non-zero. Neither says that a failing command inside it aborts the script, and those are different statements.
A script whose third command fails and whose fourth succeeds exits zero. The platform sees a
successful deployment, and the gate that failed is a line in a log nobody opens. Forge's own example
of stopping a deploy is an explicit exit 1, which sidesteps the question rather than answering it.
So set -euo pipefail at the top of the recipe is not defensive style — it is the thing that turns
a documented promise about the script's final status into a promise about every command in it.
Without it the whole chain still looks right and gates nothing.
Two more things are not established by either platform's documentation, and this page will not pretend otherwise:
-
Which shell runs the script.
-o pipefailis not in POSIXsh, so the recipe above needs bash. If you are pasting these lines into a platform's existing deploy script — which is how Forge and Envoyer work, and why this package ships no script of its own — that script decides the shell and a#!line of yours would be an inert comment inside it. Where you do own the file, start it with#!/usr/bin/env bash.⚠️ Measured, because the obvious worry is the wrong one. A shell without
pipefaildoes not quietly drop the protection and carry on:/bin/dashrefusesset -euo pipefailat line 1 withIllegal option -o pipefailand exits 2, so the script never reachesmigrateand the platform records a failed step. And on macOS/bin/shis bash in POSIX mode, which supports the option — so the two shells somebody would guess at behave in opposite ways, and neither of them is the silent one. What is genuinely unestablished is which shell YOUR platform uses, which is why this bullet is here rather than a promise that it does not matter. -
Envoyer's own deployment time limit, and whether a hook killed by a timeout is reported as failed or as canceled. Forge's page says Envoyer's limits apply when the two are integrated and does not name them. The distinction decides whether a timed-out gate is a red deploy or a silent one, and it is an observation about a running deployment rather than a claim any page makes.