Skip to main content

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

PHP8.4 or newer
Laravel13
PostgreSQL18 or newer
MySQL8.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:

CodeMeaning
0Clean — nothing breached a gate and every check reached a verdict.
1A finding crossed the level gate or the security severity gate.
2Misconfiguration — the config, baseline or a suppression could not be trusted, so nothing was actually checked. Never read this as "no findings".
3A 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:

TagWhat it publishes
sqlens-configconfig/sqlens.php
sqlens-langthe translation files
sqlensboth
A published config is a frozen copy

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.

ProfileWhat it changesWhy
localsecurity severity floor → criticalReport everything, block on almost nothing. A local gate that blocks on a finding you are halfway through fixing is a gate people stop running.
cilevel → 4, strict_tools on, strict_undetermined on, security floor pinned to highA green that skipped half its checks is worse than a red, because it is a red nobody sees.
predeploysecurity floor pinned to highThe 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 suppressed section, 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 \
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
caution
The ci profile REQUIRES the optional tools. This is not a flag you can turn off

Everywhere 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:

InvocationExitWhy
--profile=ci2strict_tools makes a missing tool a misconfiguration
--profile=ci --no-strict-tools3the missing tool is now a degradation — but it is still an undetermined, and strict_undetermined fails on it
--profile=local0neither 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.

Capture the exit code in the same step

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.

CommandWhen
sqlens:auditJudge the schema you already have, not the migrations you are about to run.
sqlens:securitySecurity 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:driftWhat the database holds, against what the migrations describe. Start with the rollout — skipping its first step kills the feature.
sqlens:formatOne house style for the SQL in your migrations, with a --check mode CI can act on.
sqlens:mcpServe 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-rulesWrite the active rule set into your project's agent context files, so an agent writing migrations knows the rules before it writes one.
Runtime guardrailsWatch 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.