0027. Report lazy-loading violations via telemetry¶
Date: 2026-09-15 Status: Accepted
Context¶
Code Quality already documents the lazy-loading
guard adopted for CFX-233: Model::preventLazyLoading($this->app->environment(['local', 'testing']))
in AppServiceProvider::boot(), so any lazy-loaded Eloquent relationship throws
Illuminate\Database\LazyLoadingViolationException in local and testing, while staging and
production were explicitly left off the allow-list and got no guard at all.
That asymmetry was always the plan, not the destination. Detection existing only in local and
testing means an N+1 that a developer's test data happens not to trigger — a table page that only
ever gets exercised with a single row locally, for example — ships to staging and production
completely invisible. There is no signal that it is happening at all, let alone how often, on which
model/relation pair, or from which call site. CFX-234 closes that gap: detection needs to run
everywhere, but what happens on detection cannot be the same in every environment, because a queue
job in staging or production that throws LazyLoadingViolationException doesn't just log a
problem — it kills the job. Turning an invisible N+1 into a job failure would be a worse outcome
than the invisible N+1.
Model::shouldBeStrict() was already rejected once for this guard, for the same reason it is
rejected again here: shouldBeStrict() doesn't only call preventLazyLoading(), it also calls
preventSilentlyDiscardingAttributes(), which breaks Filament's virtual-field handling for role
and operatedSystems. That coupling still stands, and this decision does not touch it —
preventLazyLoading() continues to be called on its own.
Decision¶
AppServiceProvider::boot() now calls Model::preventLazyLoading() unconditionally, with no
environment argument. Detection is on everywhere. The environment split moves entirely into the
reaction:
Model::preventLazyLoading();
if (! $this->app->environment(['local', 'testing'])) {
Model::handleLazyLoadingViolationUsing(function (Model $model, string $key): void {
$this->app->make(LazyLoadingViolationReporter::class)($model, $key);
});
}
local and testing register no callback, so Laravel's default behaviour — throwing
LazyLoadingViolationException — is unchanged from CFX-233. staging and production register
App\Services\LazyLoadingViolationReporter, which turns a violation into an OpenTelemetry span
event (lazy_loading_violation) instead of an exception. This is the same allow-list reasoning
CFX-233 already established for staging: staging is meant to behave like production, not like
development, so it gets production's reaction (report, don't crash) rather than development's.
AppServiceProvider::register() binds the reporter with $this->app->scoped(LazyLoadingViolationReporter::class)
— the first scoped() binding in this codebase. The reporter keeps two pieces of in-memory state
across the calls it handles within one container lifetime: a "{$model::class}:{$key}" dedup map
(so a real N+1, which invokes the callback once per hydrated row, produces one span event per
distinct model+relation pair rather than one per row) and a backtrace budget (only the first 5
distinct violations capture a trimmed, 10-frame backtrace — a deliberate cost/actionability
trade-off against reporting a backtrace on every occurrence). A static cache would have made both
of these permanent for the life of the PHP process: on a long-running queue:work worker, the first
job to trip a given model/relation pair would silently suppress every future report of it, for as
long as that worker process lives, with no way to tell that suppression was even happening. A
scoped() binding avoids that for free — Laravel's queue worker already calls
$app->forgetScopedInstances() after every job, success or failure, so the reporter's dedup state
and backtrace budget reset per job without any custom cleanup. The same reset happens between HTTP
requests, though that case is less load-bearing here since this app runs one PHP-FPM process per
request rather than Octane. Because a fresh instance is needed after that reset, the callback
re-resolves LazyLoadingViolationReporter from the container on every violation rather than
capturing one instance up front — capturing one instance would pin the reporter from whichever job
first booted the container and defeat the scoped binding's reset entirely.
The callback registered in boot() is a thin wrapper: handle(Model $model, string $key): ?array
does all the decision-making (the exists/wasRecentlyCreated skip, described below; dedup; backtrace
budget) and returns a plain array or null, while __invoke() adds exactly one line —
Span::getCurrent()->addEvent('lazy_loading_violation', $event) — on top of it. That split is
deliberate: there is no test infrastructure in this codebase for asserting that an event actually
reached a span (existing telemetry tests assert observable side effects such as job payload, log
context, or HTTP headers, not span internals), so handle() is fully unit-testable and __invoke()
is intentionally left as a thin, uncovered line rather than standing up an in-memory span exporter
for this one ticket.
handle() also reproduces a condition Laravel itself applies before it decides whether to throw:
! $model->exists || $model->wasRecentlyCreated. That check exists in Laravel only to guard the
default throw behaviour, but the violation callback fires before it, so the reporter re-implements
it — without it, every freshly created, unsaved model would be reported as a false positive.
Consequences¶
Staging and production now have visibility into lazy-loading violations that were previously
invisible there: an N+1 whose data shape doesn't trip it in local/testing (e.g. a listing page
only ever tested with one row) is no longer silent once real traffic exercises it. Nothing changes
for local or testing — a violation there still hard-fails the request or test, exactly as
CFX-233 left it. This is additive, not a replacement for fixing N+1s in development; a developer
still cannot ship past a lazy-loaded relationship locally or in CI.
The backtrace attached to a reported violation is sampled, not exhaustive — only the first 5 distinct model+relation pairs per scope (per job, per request) carry one. A violation beyond that budget still produces a span event (model, relation, no backtrace), so nothing is lost silently, but root-causing the 6th-and-later distinct violation in a busy scope requires reproducing it locally where the guard throws with a full stack trace.
There is currently no automated way to assert that a lazy_loading_violation event actually reached
a span — LazyLoadingViolationReporterTest covers handle()'s decision logic (report/skip/dedup/
backtrace-budget) directly, and LazyLoadingViolationReporterScopeTest proves the scoped-reset
behaviour by resolving the reporter from the container, recording a violation, calling
app()->forgetScopedInstances(), and confirming a fresh instance with cleared state. Neither test
touches Span::getCurrent()->addEvent(). This is a documented, deliberate scope limitation for this
ticket rather than an oversight; building an in-memory span exporter to close it would be a separate
decision if it becomes necessary.