Skip to main content

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 sayingWhat the report shows
#[RawSql(reason: '…')]this is deliberate, and here is whyno finding — the rule's question is answered
#[SqlensIgnore(rules: […], reason: '…')]I know, do not tell methe 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.

ToolThe question it answersExample of what it catches
LarastanDoes this Eloquent/facade code type-check?User::query()->wherre('id', 1) — a misspelled builder method
phpstan-dbaDoes this SQL string match the real schema, and what does it return?SELECT emial FROM users — a column that does not exist
SQLens analyseWas 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

RuleWhat it reports
SEC.INJ.RAW_INTERPOLATIONa runtime value built into the statement text instead of bound to it
SEC.INJ.DYNAMIC_IDENTIFIERa column or sort direction reaching orderBy()/groupBy()/latest()/oldest() from the request
SEC.INJ.RAW_SQL_WITHOUT_REASONa 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 commandsa PHPStan run carrying this extension
0cleanno errors
1findings above the gateerrors reported
2misconfiguration
3undetermined 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.