Integration guide
This is the page to read first. It walks the whole path — install, first run, adopting on a project that already has migrations, wiring CI, wiring the deploy gate — in the order you actually walk it, and it names the decision at each step rather than leaving it to a config file with 1,800 lines of comments.
Every command and flag below is one that ships. Where a step has a trap in it, the trap is written down beside the step.
Before you start
| PHP | 8.4 or newer |
| Laravel | 13 |
| PostgreSQL | 18 or newer |
| MySQL | 8.4 or newer |
The two database floors are not politeness. Every rule reasons about the semantics of
that specific version — PostgreSQL 18's online-DDL behavior, MySQL 8.4's ALTER
algorithms and information_schema shapes. Against an older server the same rules would
produce advice that is confident, specific, and about a different product, so a server
below the floor is reported as LINT.SERVER_BELOW_FLOOR
rather than guessed at.
MariaDB, SQLite and SQL Server are not supported and are not planned. See why an engine is unsupported.
1. Install
composer require pushery/sqlens-for-laravel
The service provider is auto-discovered. There is nothing to add to bootstrap/providers.php
and nothing to register by hand.
Confirm the commands arrived:
php artisan list sqlens
You should see eleven commands. If you see none, the package installed but discovery did
not run — php artisan package:discover fixes it.
2. Check the environment before you check your migrations
php artisan sqlens:doctor
sqlens:doctor is read-only and reports what SQLens found: the version of each optional
external tool, and — with --probe — the real server version behind each connection.
php artisan sqlens:doctor --probe # the default connection
php artisan sqlens:doctor --probe=all # every configured connection
Run this first, because two of the most confusing results later have their explanation
here. A connection SQLens cannot reach and a tool it cannot find both change what a run
can conclude, and doctor is where you see that plainly instead of inferring it from a
thinner report.
--strict answers the question a pipeline answers: would a --strict-tools run fail on
what is missing here? Use it once, locally, before you turn strict mode on in CI — it is
cheaper than finding out from a red build.
The optional tools are amplifiers, never requirements. SQLens reaches every one of its own verdicts without Squawk or the Postgres language server installed; a tool that is present adds findings SQLens cannot reach alone, and a tool that is absent is reported as a named degradation rather than a quietly smaller run.
3. Your first run
php artisan sqlens:lint
sqlens:lint reads the migrations that have not run yet on the target connection and
judges the SQL they would emit.
It needs a database connection, and this is the first thing worth being precise about,
because it surprises people who expect a linter to read files. SQLens does not parse your
migration source and guess at the SQL. It asks Laravel's own grammar what this migration
would send — by default through Laravel's --pretend, which collects the statements
without executing them. That is why the advice is about the SQL your app will really run
rather than about the Blueprint calls you wrote, and it is the reason the tool can say
ALTER TABLE … TYPE will rewrite the table when the ->change() in your file says
nothing of the kind.
The default capture mode is therefore pretend: nothing is executed, nothing is written,
no lock is taken. The two other things it can do are opt-in and behind a guard —
capture modes has the full picture.
The file fast path, for a pre-commit hook
php artisan sqlens:lint --file=database/migrations/2026_01_01_000000_add_index.php
One file, no database, sub-second. This is the mode for a pre-commit hook or an editor-save loop; the budget is held by a test rather than by a promise. Because it does not open a connection it cannot confirm which engine you are on, so it reasons about the engine your configuration names.
4. Reading the first report
Three things in the output carry most of the meaning.
The three-valued result. Every check is pass, fail, or undetermined. The third
is the one that makes this tool different: a check that could not run says so, with a
named reason, instead of being folded into the green. If a report says undetermined,
something was not measured — see understanding undetermined
for the three ways to resolve one.
The level. Findings carry a strictness level 0–9. The shipped default is level 0, and level 0 is deliberately quiet: it reports what is unambiguously dangerous and leaves taste alone. Strictness levels explains what each level adds.
The downtime class. A schema finding carries online, blocking or rewrite. This is
the field that answers "does this release need a maintenance window", and it is the one to
look at first on a table with real rows in it.
The exit code is the machine-readable half of the same answer:
| 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 | Misconfiguration — the config, baseline or a suppression could not be trusted, so nothing was actually checked. Never read this as "no findings". |
3 | A check could not run, and strict_undetermined says that is a failure. |
5. Publish the config — or don't, yet
php artisan vendor:publish --tag=sqlens-config
You do not have to. The package ships with working defaults and reads them from its own config when you have published nothing, so skipping this step is a legitimate choice for a first look.
Publish when you want to change something and keep it in version control. Three tags exist:
| Tag | What it publishes |
|---|---|
sqlens-config | config/sqlens.php |
sqlens-lang | the translation files |
sqlens | both |
Once config/sqlens.php is in your repository, it stops tracking the package. A later
release that adds a key adds it to its config file, not to yours. Re-run
vendor:publish --tag=sqlens-config --force after a package upgrade and diff the result,
or publish nothing and override only what you need in your own config/sqlens.php by
setting just the keys you care about.
The three settings worth knowing before anything else:
'level' => 0, // cumulative strictness 0–9; higher is a superset of lower
'profile' => 'local', // local | ci | predeploy — the environment this run is in
'categories' => [], // empty means every category
6. Profiles: the same config, three postures
A profile is not a second configuration. It is a named set of overrides on top of the config you already have, so a project that raises its own level keeps that level everywhere it did not deliberately ask for something else.
| Profile | What it changes | Why |
|---|---|---|
local | security severity floor → critical | Report everything, block on almost nothing. A local gate that blocks on a finding you are halfway through fixing is a gate people stop running. |
ci | level → 4, strict_tools on, strict_undetermined on, security floor pinned to high | A green that skipped half its checks is worse than a red, because it is a red nobody sees. |
predeploy | security floor pinned to high | The strictest moment of the day must not inherit a floor somebody lowered for local work. |
Select one per run with --profile=, or per environment with SQLENS_PROFILE.
Note what ci and predeploy pin rather than inherit. Both name the security floor even
where it equals the shipped default, and that is the point: a project that lowers its own
floor while working through a backlog would otherwise get a pipeline that blocks on
nothing, silently.
7. Adopting on a project that already has migrations
Run sqlens:lint on a codebase with two years of history and you will get a long report.
That is correct — and it is also useless as a gate, because a gate that is red on day one
gets switched off on day two.
The answer is a baseline: freeze what exists, block only on what is new.
php artisan sqlens:baseline --dry-run # see what would be frozen
php artisan sqlens:baseline # write it
Commit the baseline file. From then on, a run reports only findings that are not in it,
and --ignore-baseline shows you the full picture whenever you want to work the backlog
down.
Two properties are worth stating, because they are what separates a baseline from switching a check off:
- A baseline is not a suppression list. A finding it accepts stays in the report's
suppressedsection, whole, with the reason it was accepted. Nothing disappears. - A baseline entry whose finding no longer exists is reported as stale, so the file cannot quietly accumulate acceptances for code that is long gone.
--update merges new findings into an existing baseline instead of replacing it. Reach for
it deliberately: it is the operation that can hide a genuinely new problem, and it is the
one worth reviewing in a pull request.
8. Wire it into CI
Start in report mode. Ship the gate that blocks only after the report has been quiet for a few weeks.
- name: Install the analyzers the ci profile expects
run: |
npm install --no-audit --no-fund --no-save --no-package-lock \
[email protected] "@postgrestools/[email protected]"
echo "$PWD/node_modules/.bin" >> "$GITHUB_PATH"
# Creates the migration repository TABLE and runs nothing, which is what leaves every
# migration PENDING for the lint to read. Without it the database has no `migrations` table
# and the lint has nothing to reason about; with a plain `migrate` every migration has
# already run and none is pending. Both neighbors lint zero, for opposite reasons.
- name: Prepare the migration repository
run: php artisan migrate:install
- name: SQLens
run: php artisan sqlens:lint --profile=ci --assume-server-version=18.1
ci profile REQUIRES the optional tools. This is not a flag you can turn offEverywhere else in this documentation the analyzers are optional, and they are. Under
--profile=ci they stop being optional, and no combination of flags gets you back. Measured
on a runner with neither binary installed:
| Invocation | Exit | Why |
|---|---|---|
--profile=ci | 2 | strict_tools makes a missing tool a misconfiguration |
--profile=ci --no-strict-tools | 3 | the missing tool is now a degradation — but it is still an undetermined, and strict_undetermined fails on it |
--profile=local | 0 | neither flag is on |
sqlens:lint has no --allow-undetermined, so the second row has nowhere left to go. That
is the profile working as designed — it exists to say that a green which skipped half its
checks is worse than a red, because it is a red nobody sees. Install the two binaries, or
use a profile that does not make that claim.
Pin the versions. A newer build can rename a rule or reshape its report, and the adapters
are measured against these. Run sqlens:doctor --strict on the runner to get the answer
before a build gives it to you.
Two flags in that line matter more than they look.
--profile=ci turns on both strict flags, as the table above says.
--assume-server-version= is the determinism pin. Without it a run reasons about
whatever server it happens to reach, so the same commit can produce different findings on
different days as a managed database is upgraded underneath you. Pin it, and a CI result
depends on your code alone. Set it to the version you actually run in production.
To report without blocking while you adopt:
php artisan sqlens:lint --profile=ci --min-severity=none || true
For GitHub, two formats do more than console:
php artisan sqlens:lint --format=github # inline annotations on the diff
php artisan sqlens:lint --format=sarif --output=sqlens.sarif
The SARIF file uploads to GitHub code scanning; see GitHub code scanning for what GitHub does and does not weigh in it.
This is the usual way a gate silently passes:
if php artisan sqlens:lint; then echo ok; fi
status=$? # ← always 0, whatever lint returned
A failed condition with no else leaves $? at 0. Capture it directly instead:
php artisan sqlens:lint || status=$?
9. Wire the deploy gate
This is the step that pays for the others: a check that runs before migrate --force
and can actually stop it.
php artisan sqlens:predeploy || exit $?
php artisan migrate --force
php artisan sqlens:postdeploy
sqlens:predeploy reads the target database immediately before the deploy — read-only,
fail-closed, and bounded by a time budget so it can never become the thing that hangs a
release. It checks what a migration is about to walk into: a lock blocker, replication lag,
disk headroom, a missing privilege, a read-only target.
Fail-closed is the default and is the whole point. A check that could not answer stops the
deploy rather than waving it through; --allow-undetermined is the deliberate way out when
you have decided a particular unknown is acceptable.
sqlens:postdeploy reads what the deploy left behind — an index that ended up invalid, a
constraint that was never validated. Read-only, catalog-only, once.
The deploy recipe has the wiring in full, including the part people get wrong: putting the gate where a failure is reported after the migration rather than before it.
10. Optional: the rest of the suite
You do not need any of this on day one. Reach for each when the problem it solves is one you have.
| Command | When |
|---|---|
sqlens:audit | Judge the schema you already have, not the migrations you are about to run. |
sqlens:security | Security and privacy findings across every suite — roles, grants, TLS, RLS, unencrypted columns. Gated by severity rather than by level, so it runs at every strictness. |
sqlens:drift | What the database holds, against what the migrations describe. Start with the rollout — skipping its first step kills the feature. |
sqlens:format | One house style for the SQL in your migrations, with a --check mode CI can act on. |
sqlens:mcp | Serve the read-only engine to an AI agent over stdio. The one capability here that needs a package SQLens does not require — composer require --dev laravel/mcp. |
sqlens:agent-rules | Write the active rule set into your project's agent context files, so an agent writing migrations knows the rules before it writes one. |
| Runtime guardrails | Watch a running application rather than its migrations. Off unless you name a profile. |
11. When something is not what you expected
A wall of findings on the first run. Expected on an existing project. Take the baseline (step 7) rather than lowering the level — the level is what you want to raise over time, and it is a poor volume knob.
undetermined where you expected a verdict. Something could not be measured, and the
reason is named in the report. The three usual causes are a missing privilege, a missing
external tool, and a managed database that does not expose a setting. See
understanding undetermined.
Exit code 2. Nothing was checked. The configuration, the baseline or a suppression
could not be trusted, so the run refused to report a result it could not stand behind.
Read the message; it names which one.
A finding you disagree with. Three ways out, in increasing scope: the
#[SqlensIgnore] attribute on the one migration, an entry in the baseline, or a config
ignore rule. All three keep the finding in the report's suppressed section with its
reason — none of them makes it vanish.
Different results on two machines. Pin the server version
(--assume-server-version=), and check sqlens:doctor on both: a tool present on one and
absent on the other changes what each run could reach.
Where to go next
- Scope and limits — what SQLens checks, what it deliberately does not, and how it tells you which of the two you are looking at.
- The public contracts — the exit codes, the JSON envelope and the baseline format, and what changes only in a major release.
- The rules — one page per rule id, and the target every finding's documentation URL points at.
- The source — the package the install command above pulls in, and its issue tracker, which is where a rule that is wrong about your schema goes.