Skip to content

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.