Skip to main content

Runtime guardrails

Every other suite in this package reads something and ends. This one watches a running application: it turns on Eloquent's strictness switches, reports a query that took too long, and names DDL a request issued outside any migration.

It is configuration and nothing else. There is no command, no route, no middleware to register and no line to add to AppServiceProvider.

// config/sqlens.php
'guard' => [
'profile' => env('SQLENS_GUARD', null),
],

Off means absent, not cheap

With profile set to null, nothing happens: no manager is built, no logger is resolved, and no query listener is registered. That is a property held by a test rather than a claim — a safety feature that costs a closure on every query while switched off is a performance ticket waiting to happen.

The same holds one level down. A profile that only sets Eloquent strictness registers no query listener either, and a profile that wants all three query guardrails registers exactly one.

A profile name nothing defines is an ERROR

sqlens.guard.profile names `produciton`, and sqlens.guard.profiles defines only: production.

Refused at boot. The alternative is an application that runs perfectly with every guardrail silently off, over a typo, while the configuration still reads as though they are on. profile => null is how you say off on purpose.

The same applies to two named resources inside a profile: a logging.channel that config/logging.php does not define, and a connection name that database.connections does not. Both are checked at boot rather than at the first violation — a logger that throws when a guardrail finally has something to say fails at the worst possible moment.

What each profile controls

SectionKeys
strictlazy_loading, discarding_attributes, missing_attributes, destructive_commands, throw
slow_queryenabled, threshold_ms, cumulative_threshold_ms
runtimeruntime_ddl, unbound_raw_sql
loggingchannel, level, include_bindings, max_sql_length
connectionsa list of connection names, or empty for all

Three shipped profiles — local, ci, production — are examples in the literal sense: read them, adjust them, rename them. The names are yours, and everything inside a profile is validated, so a misspelled key is refused rather than quietly ignored.

throw is false everywhere but local, and that is the design

Model::preventLazyLoading(true) throws. On a developer's machine that is the fastest feedback there is and costs a refresh. In production it turns a slow page into a broken one, over a relationship that would have loaded.

So the shipped behavior is to report and let the request finish. The switch stays on — Laravel supports exactly this through handleLazyLoadingViolationUsing() — so the violation is still detected, and only what happens next changes. strict.throw is the deliberate opt-in for the environments where stopping is better than continuing.

destructive_commands is the exception and needs no reporting variant: it is Laravel's own prohibition on migrate:fresh and friends, and there is no version of running one of those against production that anybody wanted.

One combination makes the lazy-loading guardrail deaf

Laravel's automatic eager loading resolves a relation before preventLazyLoading can object. With both on, the guardrail never fires — and never firing is exactly what a working guardrail on a clean application looks like.

So the guard says so at boot, as undetermined:

the lazy-loading guardrail cannot prove it checks anything while automatic eager loading is on … a silent log is not evidence that there are none. Turn one of the two off.

Neither setting is wrong, and automatic eager loading is arguably the better answer to the same problem. What is not acceptable is believing both are working.

The cumulative budget is the one that finds N+1

A per-query threshold catches the one statement that took two seconds. It is blind to the shape that actually takes a page down: four hundred queries of four milliseconds each, none of them remotely slow, adding up to a second and a half in one request.

cumulative_threshold_ms runs beside it, reports once per request, and carries the query COUNT — which is the field that separates one slow query from four hundred fast ones. The two need opposite fixes and reach the same total.

Every record has the same fields, and status is one of them

A violation is logged as a structured record, not a sentence:

{
"sqlens_guard": "production",
"violation": "slow_query",
"status": "fail",
"category": "performance",
"connection": "pgsql",
"sql": "select * from orders where id = ?",
"time_ms": 2431,
"threshold_ms": 2000
}

status is a field, and that is load-bearing. A guardrail that could not establish anything — see the masking case above — logs "status": "undetermined" with a reason beside it. If the only thing separating that from a real finding were English prose, an aggregator would count them together: the silent green, arriving through the one channel nobody diffs.

Keys are sorted, so two identical violations produce byte-identical records. An unstable key order makes every log diff a change, which is how somebody stops reading them.

A security violation carries a severity and it raises the level — never lowers it. The profile's level is a floor: a profile logging at error has said what it wants to see, and a severity demoting a record below that would hide it from the filter somebody set up.

Bindings are logged as SHAPES, or not at all

include_bindings is false by default, and it is the one default not to change without saying why. Bindings are row data, and a log is the one destination where database contents land somewhere with different access rules than the database they came from — usually shipped off the host entirely.

And true does not mean "log the values". It means log their shape:

"bindings": ["string(18)", "true", "int"]

A binding can be a password, a token or somebody's address. string(18) answers the question a developer actually has — did it bind a string or an integer, and was it empty — without putting row data into a file that leaves the host. Booleans and null are printed outright: neither can carry a secret, and knowing which of false, null and 0 was bound is the whole question in a WHERE clause that matched nothing.

The SQL itself is truncated at max_sql_length and the cut is marked. An unmarked truncation is worse than a long line: a reader sees a statement that appears to end where it does not.

In a queue worker or under Octane, the window still ends

A worker is ONE process handling thousands of jobs. An accumulator that never reset would measure the worker's uptime rather than a request — and a latch that never reset would report the first crossing and then stay silent forever, which reads exactly like an application that got faster.

So the cumulative window ends on the lifecycle events those processes fire — RequestHandled, JobProcessing, CommandStarting — and the next one starts from zero. Nothing to configure, and nothing registered when a profile has nothing to accumulate.

What it deliberately cannot see

  • unbound_raw_sql reports a SHAPE, not an injection. A statement carrying 'active' where a binding would go might be a hand-written constant or a request parameter, and nothing at the query layer separates them. The static analysis suite answers that question properly, by reading the code that built the statement.

  • runtime_ddl excludes console processes, because migrations, seeders and maintenance commands all legitimately issue DDL. What it reports is DDL from a request — where an application has no business issuing any.

    It judges the leading keyword and nothing else. That survives every quoting style, so the drift a full parse would protect against is not drift this reading can suffer — and a parse per query is a cost every request would pay for a check that fires on almost none of them. The one thing it cannot read is a statement that opens with a comment, which query-tagging middleware prepends to everything; that case is reported as undetermined with its reason, not waved through.

  • It is not monitoring, and the slow-query finding says so in its own message rather than only here: trends, percentiles and history are an APM question — Laravel Pulse, Sentry, whatever you already run — and this package deliberately keeps none of them. A boundary stated only in documentation is a boundary somebody relitigates at 3 a.m.; one stated in the finding is read at the moment it matters.

The active profile is on the record

A run is only reproducible if its parameters are stated, and "none" is a parameter. Every report header carries guard_profile — the active profile name, or the literal string off:

"guard_profile": "production"

It is never omitted. A deactivated guard that does not appear in a header reads exactly like an active one, and a reader scanning for it and finding nothing concludes the field is not emitted by this version rather than that the guardrails are disabled.

Do not confuse it with guard one field over: that is the production guard's verdict on a shadow provision. The two share a word and nothing else.

sqlens:doctor carries a fuller section — which guardrails a profile armed, which log channel it writes to, and whether it throws. It also answers undetermined, with the reason named, for two states that are neither on nor off:

  • guard_watches_unused_connection — the profile names a connection this application does not define, so the guardrails are armed and will never see a query.
  • guard_profile_arms_nothing — the profile resolves and every guardrail in it is false, so naming it changes nothing.

And it is the one place a broken profile is described rather than fatal: everywhere else an unknown name ends the boot, which is right — but the command whose job is diagnosing a broken setup has to live long enough to describe one.

It never becomes the reason a request fails

A logging failure is swallowed. A guardrail that took an application down while reporting a problem the application had survived would be causing the outage it was watching for — and the one configuration error that could reach that path is refused at boot instead.