The lint suite
sqlens:lint checks a connection's pending migrations for unsafe schema changes
before they reach a database. It resolves which migrations are pending, captures
each one's SQL without executing it, canonicalizes it, runs the rules, and reports a
three-valued result — pass, fail, or undetermined — with a reproducibility
header at the top. The exit code is the whole integration surface: a pipeline never
has to parse the output to decide what happened.
sqlens:baseline freezes the current findings into a repo file so an existing
project can adopt SQLens without drowning in its first run — from then on the linter
reports only what is new.
sqlens:lint
php artisan sqlens:lint
By default it lints the pending migrations of the resolved connection in --pretend
mode: it collects the SQL Laravel would run without executing it, so it never touches
your data and never takes a lock.
Options
| Option | What it does |
|---|---|
--connection= | The connection to lint. Defaults to sqlens.connection, then the application default. It addresses the write side of a read/write split, so a lagging replica cannot mislead it. |
--path= | A migration path to lint; repeatable. Resolved as --path over the configured sqlens.migration_paths over the application's registered migration paths, so a project that always lints the same non-default paths can set them once in config and the flag still wins per run. |
--file= | Lint exactly one migration file — the fast path for a pre-commit hook or an editor save, without a database connection. The file must live inside a configured migration path (--file is a migration linter, not an arbitrary file linter). It runs the same capture, rules, gates, and suppression as a full run, so a baseline entry or a #[SqlensIgnore] suppresses the same findings. Pretend-only: it cannot be combined with --shadow, and only one --file is accepted. |
--profile= | The environment profile — local, ci, or predeploy — which presets the strictness settings for that environment (see below). Overrides SQLENS_PROFILE and the configured profile. |
--level=0..9 | The cumulative strictness level. Level n runs every rule at level <= n (Larastan's model). Defaults to sqlens.level. Security and privacy rules ignore this gate — a level-0 run can still report a critical. |
--category= | Scope the run to one or more of safety, performance, idiom, convention, security, privacy; repeatable and comma-separable. Defaults to sqlens.categories; empty means all. A scope that matches no rule is reported as undetermined, never a silent pass. |
--format= | The report format: console, json, or github. Defaults to sqlens.reporting.default_format. |
--output= | Write the report to a file instead of STDOUT. |
--assume-server-version= | Reason about this server version instead of the detected one — the determinism pin for CI (see below). |
--strict-tools / --no-strict-tools | Whether a missing optional external tool is an error or a degradation, overriding sqlens.strict_tools (see below). |
--pretend | Collect each migration's SQL without executing it — the default, do-no-harm mode. |
--shadow | Run the migrations for real against a throwaway database SQLens provisions and drops itself, and read the SQL off the wire — the truth mode for what pretend structurally cannot capture. It runs only behind the production guard and never clones your data. See Shadow mode. |
--roundtrip | Replay up → down → up inside that throwaway database to find out whether down() is a real inverse. Shadow only, and destructive by design — see the warning below. |
--force | Confirm a shadow run without being asked, for a pipeline with nobody at the keyboard. It waives the confirmation and nothing else: it can neither move the mode into an environment the configuration does not allow, nor point it at a connection that looks like production. |
Warnings and diagnostics go to STDERR, so --format=json leaves STDOUT carrying
nothing but the JSON document a pipeline can parse.
Formats
console is the human balance. json is one deterministic, machine-readable
envelope — no ANSI, the level and severity axes as separate fields, a stable key
order, a trailing newline. github emits one workflow annotation per finding
(::error / ::warning / ::notice), escaped per GitHub's rules and anchored to the
finding's file and line so it lands on the diff of a pull request:
php artisan sqlens:lint --connection=pgsql --path=database/migrations --level=4 --category=safety,performance --format=github
The fast path: linting one file
--file lints exactly one migration and opens no database connection, which is what
makes it usable where a full run would be too slow or has nothing to connect to — a
pre-commit hook, an editor save, a container that has no database.
It is the same engine. Only the subject SOURCE differs: the file replaces the pending
set, and everything after that — capture, canonicalization, rule selection, both
gates, suppression, the reporter — is the code a full run uses. A baseline entry and a
migration-class #[SqlensIgnore] therefore suppress the same findings either way. The
package proves this rather than promising it: a parity suite lints the same corpus
whole and file-by-file against a real PostgreSQL and a real MySQL server and requires
the two to agree, and an architecture test keeps the fast path wired at a single site
so it cannot quietly grow into a second pipeline.
Because it never connects, it cannot read the server version. Pin one so
version-dependent rules can be evaluated deterministically; without a pin they report
undetermined rather than guessing:
php artisan sqlens:lint --file=database/migrations/2026_01_01_000000_create_orders_table.php --assume-server-version=18.0
A pre-commit hook
Save this as .git/hooks/pre-commit and make it executable
(chmod +x .git/hooks/pre-commit). It lints every staged migration and blocks the
commit if any of them fails:
#!/usr/bin/env bash
set -euo pipefail
status=0
while IFS= read -r file; do
[ -f "$file" ] || continue # skip a staged deletion
php artisan sqlens:lint --file="$file" --assume-server-version=18.0 || status=$?
done < <(git diff --cached --name-only --diff-filter=ACMR -- 'database/migrations/*.php')
exit "$status"
Two details worth keeping. --diff-filter=ACMR leaves out deletions, so a removed
migration does not fail a hook by no longer existing — the [ -f ] guard covers a
rename edge as well. And the loop keeps the LAST non-zero exit rather than stopping at
the first failure, so one commit reports every problem instead of one per attempt.
The roundtrip: proving down() is a real inverse
--roundtripexecutesdown()for real. It exists only inside the throwaway database shadow mode creates, becausedown()is allowed to drop things — that is its job. It is refused outside shadow mode, refused against a connection you name, and refused together with--file. The refusal is in the code, not in this paragraph: each case stops the run with the misconfiguration exit before anything executes, so a mistyped command can never replay a rollback against a database you care about.
Whether a migration's down() truly undoes its up() cannot be read off the SQL. It
is a property of the state down() leaves behind, so the only way to answer it is to
undo and redo:
php artisan sqlens:lint --shadow --roundtrip
All three legs — up, then down, then up again — run against the same
throwaway database. Running each against its own fresh copy would prove nothing: the
question is whether the second up still works on the state down left. The down
leg runs in reverse order, because migrations undo in the opposite order they applied,
and a leg that does not come through clean ends the roundtrip rather than judging the
next one against a state nobody can reason about. The legs that did run are kept —
they are the evidence.
Each of the three ways a roundtrip can fail gets its own answer, because each sends you to a different place:
| What failed | Finding | What it means |
|---|---|---|
The first up | CAP.L0.MIGRATE_ERROR | The migration itself is broken. down() was never reached, so nothing is known about it. |
The down leg | CAP.L0.DOWN_FAILED | The rollback path — the one that runs when a deploy is already going wrong — cannot execute. |
The second up | CAP.L0.DOWN_NOT_INVERTIBLE | Both legs ran, and down() left a schema the migration cannot be applied to again. |
The last two are deliberately separate. A down() that throws and a down() that
runs but does not restore are different defects with different fixes, and the first
makes the second unanswerable: a down that did not finish left behind nothing anyone
can judge. Claiming the stronger verdict there would be inventing evidence the run does
not have.
A migration that declares no down() at all stops the roundtrip too, as
roundtrip_no_down_method — never as a clean, empty leg. "There is no rollback path"
and "the rollback path does not invert" are different facts, and only the first one is
true there.
When a migration is deliberately irreversible — a data migration you accept you cannot undo — say so on the migration class, and the finding is reported as suppressed with your reason rather than disappearing:
return new #[SqlensIgnore(rules: ['CAP.L0.DOWN_NOT_INVERTIBLE'], reason: 'one-way backfill, restored from backup instead')] class extends Migration
{
// ...
};
Every report states whether the roundtrip ran — roundtrip=on|off in the console and
GitHub headers, roundtrip in the JSON run object — stated even when it is off. A
report that simply did not ask about down() must not be readable as one that asked
and was satisfied.
Profiles
A profile bundles the settings that differ between a developer's machine and a pipeline, so the difference is one named choice instead of six flags remembered separately. It is a partial override written in the same shape as the rest of the configuration — a setting a profile does not name keeps its configured value.
| Profile | What it presets |
|---|---|
local | Nothing. It is your configuration under an explicit name, so a project that raised its own level keeps that level. The shipped defaults are already lenient, so this is the report-without-blocking profile. |
ci | level 4, strict_tools on, strict_undetermined on. A missing tool and a check that could not run both fail the build, because a green that skipped half its checks is a red nobody sees. |
predeploy | Nothing yet. The name and the mechanics exist; what the pre-deploy gate enforces arrives with the deploy suite. |
A profile may override six settings: level, strict_tools, strict_undetermined,
use_statistics, assume_server_version, and security.min_severity. Anything else
in a profile is a configuration error naming the settings that are overridable.
The active profile is resolved in one fixed order, and the command line always wins:
--profile= → SQLENS_PROFILE → the configured profile → local
APP_ENV is deliberately never consulted. Deriving the profile from the framework
environment would mean a machine whose APP_ENV happened to be production silently
ran a different gate than the same command on a laptop. If a pipeline wants the ci
profile, it says so — with the flag, or by exporting SQLENS_PROFILE=ci. An unknown
or empty profile is a configuration error naming the valid set, never a quiet fall
back to the lenient default.
The active profile and the settings it produced appear in the run header of every report, so a result can always be traced to the conditions that produced it.
Exit codes
Every command returns one of four codes, and they are the whole interface:
| Code | Meaning |
|---|---|
0 | Clean: nothing breached a gate, and every check reached a verdict. |
1 | A finding crossed the level gate or the security severity gate. |
2 | The configuration, baseline, or a tool could not be trusted, so nothing was checked on that basis. |
3 | A check could not run, and strict mode says that is a failure. |
A misconfiguration outranks everything, including a run with no findings: a tool that
cannot trust its own setup never reports green. An undetermined result — a check
that could not run — is never silently a 0; under strict mode it is a 3, and in
every mode it is visible in the report.
What is refused as a misconfiguration
A flag SQLens cannot honor is refused by name before anything runs — never dropped quietly, which would let you believe a run did something it did not:
- An unknown
--format,--categoryor--profile, a--leveloutside0..9, or an--outputfile that cannot be written. - A
--filethat does not exist, is not a PHP migration, or sits outside the configured migration paths; more than one--file;--filetogether with--shadow. --roundtripwithout--shadow,--roundtripwith--connection=, or--roundtriptogether with--file.- A connection whose engine SQLens does not target, and a baseline file it cannot read.
sqlens:baseline
An established schema will report findings on the first run. Freeze them so the linter reports only what is new from then on:
php artisan sqlens:baseline
The baseline is written to sqlens.baseline.path — set it first, and commit the file
to the repository. It is a repo artifact, never a database write. Only failing
findings are frozen; an undetermined is never baselined.
| Option | What it does |
|---|---|
--connection= | The connection to lint, as for sqlens:lint. |
--path= | A migration path to lint; repeatable. |
--profile= | The environment profile, resolved exactly as for sqlens:lint — so a baseline is frozen under the same settings the gate will judge against. |
--update | Merge the current findings into an existing baseline additively — a recorded finding stays, a new one is added, a fixed one is left until a fresh write drops it. |
--dry-run | Report what would be written without touching the file. |
The file is deterministic and merge-arm by construction: the entries are sorted stably, one finding per line, with a trailing newline and no timestamp, host, or absolute path. Two runs on the same state produce byte-identical output.
Adopting on an existing project
Report before you block — the safe order for adopting a gate on a schema that already has findings:
# 1. Freeze today's findings so they do not fail the build.
php artisan sqlens:baseline
# 2. Commit the baseline file.
git add .sqlens-baseline.json && git commit -m "Adopt SQLens baseline"
# 3. Arm the gate: from now on, only NEW findings fail CI.
php artisan sqlens:lint
Resolving a merge conflict in the baseline
Because the file is one finding per line, a merge conflict resolves line by line: keep both sides' finding lines and drop the conflict markers. Never regenerate the whole file to resolve a conflict — that discards findings the other branch accepted.
Suppression
A finding can be suppressed by four sources, applied in a fixed order — baseline, then
config, then annotation, then the destructive opt-in — from the broadest instruction to
the most specific. The first source that covers a finding wins, and its source is
recorded in the report. A suppressed finding is never silent: it is listed, counted per
source, and any recorded suppression that matched nothing this run is surfaced as
stale.
An undetermined is never suppressed by a baseline or an annotation — hiding a check
that could not run would hide the fact that it never ran. The only exception is
explicit and per reason, under sqlens.suppression.allow_undetermined.
The annotation source reads the #[SqlensIgnore] attribute off the migration class:
use Illuminate\Database\Migrations\Migration;
use Pushery\SQLens\Attributes\SqlensIgnore;
return new #[SqlensIgnore(rules: ['PG.L2.CONCURRENTLY'], reason: 'legacy table, migrated out of band')] class extends Migration
{
public function up(): void
{
// ...
}
};
The reason is required — a suppression without one is unconstructible.
The destructive opt-in is the fourth source, and it is a consent rather than a hidden
skip. A destructive operation — DROP TABLE, DROP COLUMN, TRUNCATE, a WHERE-less
mass UPDATE/DELETE, or a drop without a deploy window — always produces its finding;
the opt-in moves that finding to the suppressed list with a named reason, so a run still
shows the migration destroys data. It comes from either the migration's own
#[SqlensAllowDestructive(reason: '…')] attribute, whose reason is shown, or the
project-wide sqlens.allow_destructive config switch for a project that is destructive
by design — and the attribute's reason wins over the switch. Like every other setting, a
typo in allow_destructive is a configuration error, never a silent no-op.
Continuous integration
Wire the exit code into CI to fail a build on an unsafe migration. With
--format=github, findings also surface as annotations on the pull request's diff:
# .github/workflows/sqlens.yml
name: SQLens
on: [pull_request]
jobs:
lint:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:18
env:
POSTGRES_PASSWORD: postgres
ports: ['5432:5432']
steps:
- uses: actions/checkout@v4
- uses: shivammathur/setup-php@v2
with:
php-version: '8.4'
- run: composer install --no-interaction --no-progress
- run: php artisan sqlens:lint --format=github --assume-server-version=18.0 --strict-tools
env:
DB_CONNECTION: pgsql
DB_HOST: 127.0.0.1
DB_PASSWORD: postgres
Why --assume-server-version and --strict-tools
A CI run without both switches says less than it looks. --assume-server-version pins
the version the rules reason about, so a version-dependent rule activates identically
on a developer's machine and in CI — without it, the two can disagree by the server's
minor version. --strict-tools turns a missing optional external tool from a silent
loss of coverage into an error — without it, a green run on a machine that lacks a tool
is simply a run with fewer rules. Together they make a green CI run mean the same thing
everywhere.
See also
- The public contracts — the exit codes, the JSON envelope, and the baseline file format, and what each promises across versions.
- Understanding
undetermined— the three-valued result model and the three ways to resolve a finding. - Capture modes: pretend and shadow — how SQLens obtains a migration's SQL, and what pretend mode structurally cannot see.
- Shadow mode: setup, privileges, topologies — what
--shadowand--roundtripneed before you turn them on: the dedicated least-privilege role, the production guard, and why the throwaway database is never a copy of yours.