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.

And to the profile itself. A key this build does not read, a logging.level PSR-3 does not name (warn rather than warning) and a section that is not a map of keys all end the boot, naming what they found. The config validator refuses the same things in a sqlens:* command, but the guard is armed at every boot of the application, where no such command runs: a misspelled key would leave its guardrail off, and an unknown level would make the logger throw inside the guard and lose the record.

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.

A profile you add takes every key it leaves out from the shipped production profile. So it can name only what differs and still watches everything production watches:

'profiles' => [
// Runtime DDL, unbound raw SQL, slow queries and destructive commands all come from
// `production`; only throwing on a lazy load is this profile's own.
'staging' => [
'strict' => ['throw' => true],
],
],

A shipped profile you publish in part keeps its own values for the rest, so a partial local is still local. If a configuration in use still lacks a key in a profile, for example a cached configuration written before an upgrade, the guard reads that switch as off, and every sqlens:* command refuses to run until the cache is rebuilt or the key is written.

These are the guard's profiles, under sqlens.guard.profiles. The lint and audit runs have their own set under sqlens.profiles, named local, ci and predeploy — a different registry with a different third name. See Profiles.

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 once the application has booted, 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 check waits for every provider to boot, because the application's own providers boot after this package's and AppServiceProvider::boot() is where automatic eager loading is usually turned on. For the same reason the guard also reports, as guardrail_overridden, a guardrail the profile turns on that a later provider switched back off, with Model::preventLazyLoading(false) or Model::shouldBeStrict(false): the profile still names it, and it checks nothing.

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 statement text gets the same treatment, whatever include_bindings says. A value written into the SQL is the same row data as a bound one, and unbound_raw_sql reports exactly the statements that carry one, so every literal in a logged statement is logged as its length:

"sql": "select * from users where email = '<string(17)>'"

Where a literal ends is a question of the engine's grammar. On PostgreSQL and MySQL it is read from the connection's driver, comments, dollar quotes and E'…' strings included. On an engine SQLens has no grammar for, such as SQLite or SQL Server, a quoted value cannot be told from a quoted name, and everything from the first quote to the last is logged as <masked>.

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.

    The shape is a quoted literal after a comparison, LIKE or IN (. A number is not matched, though concatenating one is the same mistake: the framework writes numbers into statements itself, the integer keys of every eager load and the count of a has(), so a guard that matched them would report nearly every request. Two things the framework writes are not reported either: a JSON path, whose ->> is not a comparison, and the schema builder's own questions (Schema::hasTable(), getColumns() and the rest), which carry the names they ask about as literals. Those are recognized by the shape the connection's own schema grammar compiles, on PostgreSQL and MySQL.

  • runtime_ddl excludes the Artisan commands that change a schema on purpose, because migrations, seeders and maintenance commands all legitimately issue DDL. What it reports is DDL from a request, from a queue worker (queue:work, queue:listen, horizon:work) and from the scheduler (schedule:run, schedule:work): a job and a scheduled closure are application code, which has no business issuing any. Under Octane a worker is not a console process, so every request is watched.

    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 configuration errors that could reach that path, the channel and the level, are refused at boot instead.

A report never sets off another one. A log channel that writes to a database the profile watches runs a statement of its own while a report is written, and that statement is not inspected: judged again, a slow log write would be reported, and its report would write again.