Skip to main content

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

OptionWhat 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..9The 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-toolsWhether a missing optional external tool is an error or a degradation, overriding sqlens.strict_tools (see below).
--pretendCollect each migration's SQL without executing it — the default, do-no-harm mode.
--shadowRun 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.
--roundtripReplay updownup inside that throwaway database to find out whether down() is a real inverse. Shadow only, and destructive by design — see the warning below.
--forceConfirm 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

--roundtrip executes down() for real. It exists only inside the throwaway database shadow mode creates, because down() 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 failedFindingWhat it means
The first upCAP.L0.MIGRATE_ERRORThe migration itself is broken. down() was never reached, so nothing is known about it.
The down legCAP.L0.DOWN_FAILEDThe rollback path — the one that runs when a deploy is already going wrong — cannot execute.
The second upCAP.L0.DOWN_NOT_INVERTIBLEBoth 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.

ProfileWhat it presets
localNothing. 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.
cilevel 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.
predeployNothing 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:

CodeMeaning
0Clean: nothing breached a gate, and every check reached a verdict.
1A finding crossed the level gate or the security severity gate.
2The configuration, baseline, or a tool could not be trusted, so nothing was checked on that basis.
3A 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, --category or --profile, a --level outside 0..9, or an --output file that cannot be written.
  • A --file that does not exist, is not a PHP migration, or sits outside the configured migration paths; more than one --file; --file together with --shadow.
  • --roundtrip without --shadow, --roundtrip with --connection=, or --roundtrip together 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.

OptionWhat 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.
--updateMerge 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-runReport 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