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
| Section | Keys |
|---|---|
strict | lazy_loading, discarding_attributes, missing_attributes, destructive_commands, throw |
slow_query | enabled, threshold_ms, cumulative_threshold_ms |
runtime | runtime_ddl, unbound_raw_sql |
logging | channel, level, include_bindings, max_sql_length |
connections | a 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_sqlreports 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_ddlexcludes 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
undeterminedwith 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.