The `analyse` suite: reading the code that hands SQL to the database
Every other suite in SQLens judges a statement: something the database received, or
would have received. The analyse suite judges the code that produced it. That is the
only place where a decision is visible at all — by the time a query reaches the database,
nobody can tell whether its author reached for raw SQL deliberately or by habit.
It ships as a PHPStan extension, so it runs where your static analysis already runs.
Turning it on
If you use composer's PHPStan plugin, it
is already on — the package declares the extension in extra.phpstan.includes. Otherwise
name it yourself:
includes:
- vendor/pushery/sqlens-for-laravel/extension.neon
Nothing else is required. The extension adds rules; it does not change your level, your paths, or your baseline.
What it asks
Three questions, and only one of them is about a written reason.
Was a runtime value built into the statement text instead of bound to it?
SEC.INJ.RAW_INTERPOLATION reports it at high severity — that property is what decides
whether an injection is possible at all.
Is a column or a sort direction reaching orderBy(), groupBy(), latest() or
oldest() from the request? SEC.INJ.DYNAMIC_IDENTIFIER, also high: an identifier is
part of the statement rather than a value, so it cannot be bound and an allowlist is the
only fix.
Is there a written reason for this raw-SQL call site? SEC.INJ.RAW_SQL_WITHOUT_REASON
reports a DB::select(), DB::statement(), DB::unprepared() — every facade method that
hands SQL text to the database — when nothing at that call site says why. It is a policy
question rather than an exposure, and it is the only one analyse.policy governs: setting
that to off retires the justification duty and leaves the two injection rules reporting.
Answering the third takes one attribute:
use Pushery\SQLens\Attributes\RawSql;
final class MonthlyReport
{
#[RawSql(reason: 'the query builder cannot express a LATERAL join')]
public function build(): array
{
return DB::select('SELECT … FROM orders o, LATERAL (…) l');
}
}
The reason is mandatory, and an empty one does not count. Nothing parses it — it is for the next reader, not for the rule.
The attribute targets a method or a class. Prefer the method: a class-level reason covers every call in that class, so one reasoned raw statement and one careless one are excused together.
It is not a suppression, and the difference shows up in your report
Two things look similar and are not:
| What you are saying | What the report shows | |
|---|---|---|
#[RawSql(reason: '…')] | this is deliberate, and here is why | no finding — the rule's question is answered |
#[SqlensIgnore(rules: […], reason: '…')] | I know, do not tell me | the finding exists and is recorded as suppressed |
Keep them apart. Route both through one channel and a report can no longer distinguish
somebody looked at this and accepted it from this was never a finding — which is the
entire reason SQLens records a suppressed_by at all.
What it deliberately does not do
It never reads your query text. The rule does not ask whether the SQL is dangerous, whether its values are bound, or where they came from. Deciding that from a call site is taint analysis, and SQLens does not do taint analysis. A rule that implied otherwise would be making a promise its author knows it cannot keep.
It sees exactly one call site. No value flow across function boundaries, no assignment chains through other scopes. Raw SQL reached through a variable, a callable string or a container binding is invisible to it — and the suite reports what it saw rather than claiming the rest is clean.
Where it can see the statement's text, it classifies it three ways rather than two:
parametrized when the text is fully known at analysis time, interpolated when a runtime
value visibly reached its shape, and undetermined with a named reason when it cannot
tell. An argument it cannot resolve is never quietly called parameterized — see
Understanding undetermined,
which is the same third value the rest of the package uses.
This is also what keeps the suite quiet. DB::select('… WHERE status = ?', [$status]) and
a lookup into a constant map are recognized as parameterized, so you are not asked about
the patterns you already use correctly.
Where Larastan and phpstan-dba fit
Three tools, three different questions. None of them replaces another, and running all three is the intended arrangement rather than a compromise.
| Tool | The question it answers | Example of what it catches |
|---|---|---|
| Larastan | Does this Eloquent/facade code type-check? | User::query()->wherre('id', 1) — a misspelled builder method |
| phpstan-dba | Does this SQL string match the real schema, and what does it return? | SELECT emial FROM users — a column that does not exist |
SQLens analyse | Was raw SQL a decision, and is it written down? | a DB::statement() nobody explained |
SQLens does not type-check SQL, and that is not a gap to be filled later. phpstan-dba connects to your database (or replays a recorded schema), parses the query, and knows the result shape. Doing that a second time inside SQLens would mean a second parser, a second schema reader and a second set of answers free to disagree with the first — while SQLens' own first principle is that a check has exactly one owner.
The two compose cleanly because they read different things. phpstan-dba reads the string; SQLens reads whether a human stood behind it.
Running them together
Include both extensions and analyze once:
includes:
- vendor/larastan/larastan/extension.neon
- vendor/staabm/phpstan-dba/config/dba.neon
- vendor/pushery/sqlens-for-laravel/extension.neon
Two practical notes:
- phpstan-dba needs a reflector — a live connection or a recorded schema — and that setup is described in its own documentation. SQLens needs none: it reads source, so it runs on a laptop with no database at all.
- Order does not matter. The two extensions register independent rules over the same call sites and never consult each other.
Why SQLens ships no setup command for it
It was considered and decided against. Such a command would have to know phpstan-dba's reflector options, its cache file format and its configuration keys — and it would go stale on the release after next, silently, because nothing in this package's test suite exercises another package's configuration surface.
Worse, it would put SQLens in the position of owning a decision that is not its own: which reflector suits your project depends on whether your CI has a database, whether your schema is dumped, and how fast you need the analysis to be. Those are questions phpstan-dba already answers in its own words.
So the bridge is documentation, deliberately. If you want the two working together, the page above is the whole integration.
Rules in this suite
| Rule | What it reports |
|---|---|
SEC.INJ.RAW_INTERPOLATION | a runtime value built into the statement text instead of bound to it |
SEC.INJ.DYNAMIC_IDENTIFIER | a column or sort direction reaching orderBy()/groupBy()/latest()/oldest() from the request |
SEC.INJ.RAW_SQL_WITHOUT_REASON | a raw-SQL call site with no written reason |
What runs the suite is PHPStan, and that shows in two places
Every other SQLens suite is an Artisan command this package owns. The analyse suite is a PHPStan
extension, so PHPStan owns the run — its output formats, its baseline, and its exit code. Two
consequences are worth knowing before you wire it into CI, because both are quiet.
Findings go into PHPStan's baseline, not SQLens' baseline
SQLens has a baseline of its own: a file the lint and audit suites write and read, holding
findings a project has decided to accept for now. An analyse finding never reaches it. It is a
PHPStan error, so it is suppressed the way PHPStan errors are suppressed:
parameters:
ignoreErrors:
- identifier: sqlens.rawSql.interpolation
- identifier: sqlens.rawSql.dynamicIdentifier
- identifier: sqlens.rawSql.unjustified
or by regenerating phpstan-baseline.neon with --generate-baseline, which is what most projects
already do.
That is deliberate rather than an omission. A run has one baseline or the other, and a project would
have to keep two files in step for a single tool if these findings tried to use both. It does mean
that accepting a raw-SQL call site in the lint baseline does nothing here, and the other way round.
reportUnmatchedIgnoredErrors is on by default, so a suppression that stops matching becomes an
error rather than silent slack — which is the same property SQLens' own stale-baseline policy has.
The exit code is PHPStan's, and it is NOT SQLens' exit-code contract
They agree on the two values you meet most often, which is exactly why the difference goes unnoticed:
| SQLens commands | a PHPStan run carrying this extension | |
|---|---|---|
0 | clean | no errors |
1 | findings above the gate | errors reported |
2 | misconfiguration | — |
3 | undetermined while strict mode is on | — |
PHPStan has no 2 and no 3. A pipeline that branches on 3 to catch "a check could not
answer" will never see it from an analyse run: an undetermined call site is not reported at all
here — it is carried in the collected data and left out of the findings, because raising doubt at
high severity is how a security rule teaches a team to ignore it.
So do not read a green analyse run as "the three-valued contract said pass". It said nothing was
reported, which for this suite is a narrower claim.
Handing the suite a PHPStan run you already made
security.analyse.result_path points at the JSON output of a PHPStan run, so the security suite can
read findings a separate CI step already produced instead of running the analyzer again.
// config/sqlens.php
'security' => ['analyse' => ['result_path' => env('SQLENS_ANALYSE_RESULT_PATH')]],
vendor/bin/phpstan analyse --error-format=json > build/phpstan.json
php artisan sqlens:security
The path is repository-relative like every other path in this configuration; an absolute one is accepted too, because a CI step that knows its own workspace should not have to compute a way back to it.
Diagnostics whose identifier belongs to no SQLens analyse rule are dropped. A project runs
PHPStan for its own reasons, and its own errors are not this suite's to relay.