Skip to main content

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:

OptionWhat it doesWhat breaks without it
-estop at the first command that exits non-zeromigrate --force runs anyway. The gate reports, the deploy proceeds, and the report is read afterwards — if at all.
-o pipefaila pipeline fails when any stage fails, not just the lastpredeploy | 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.
-uan unset variable is an errorA 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:

CodeMeaning
0clean: run the migration
1findings above the gate — a fact about the database
2misconfiguration: the run never happened, so nothing was established
3undetermined 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 pipefail is not in POSIX sh, 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 pipefail does not quietly drop the protection and carry on: /bin/dash refuses set -euo pipefail at line 1 with Illegal option -o pipefail and exits 2, so the script never reaches migrate and the platform records a failed step. And on macOS /bin/sh is 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.