In Laravel, what does Model::preventLazyLoading() do, how should you enable it, and which lazy loads does it deliberately let through?
answer
- a static switch in AppServiceProvider::boot
- ! $this->app->isProduction()
- LazyLoadingViolationException on the dynamic property
- only models from multi-row results
- handleLazyLoadingViolationUsing to log instead
basics
~20 sModel::preventLazyLoading() makes Eloquent throw LazyLoadingViolationException when an unloaded relation is read as a property on a model that came from a multi-row result. Enable it outside production in AppServiceProvider::boot, or register a handler that logs.
solid answer
~40 s`Model::preventLazyLoading()` is a static switch, usually called in `AppServiceProvider::boot()` as `Model::preventLazyLoading(! $this->app->isProduction())`. When on, reading an unloaded relation as a dynamic property throws `Illuminate\Database\LazyLoadingViolationException` instead of running the query. It is deliberately narrow: the flag is only set on models hydrated from a result of **more than one row**, so `Post::find(1)->author` still lazy loads; new or just-created models do not throw; and explicit relation calls like `$post->comments()->get()` are not lazy loads at all. `Model::handleLazyLoadingViolationUsing()` replaces the throw with your own callback, which is how teams log violations in production instead of failing requests. `Model::shouldBeStrict()` turns it on together with `preventSilentlyDiscardingAttributes` and `preventAccessingMissingAttributes`.
go deeper
Recall that preventLazyLoading turns silent per-row relation queries into an exception, and that it is switched on once in AppServiceProvider::boot.
Explain the exception class, why single-model results and new models are exempt, and what shouldBeStrict adds beyond lazy loading.
Show the production call: throw locally and in tests, log through handleLazyLoadingViolationUsing in production, and roll it out on a legacy codebase without breaking pages.
Weigh strictness as a team policy: fail-fast in production versus observability, and how violations feed into performance budgets and review rules.
## What the switch does Eloquent lets you read a relation as a **dynamic property**, `$post->author`, and loads it on first access if nobody eager loaded it. That convenience is how N+1 query patterns reach production unnoticed. `Model::preventLazyLoading()` turns the silent query into an error during development: ```php use Illuminate\Database\Eloquent\Model; public function boot(): void { Model::preventLazyLoading(! $this->app->isProduction()); } ``` It is a **static, global** setting on the base `Model` class, so one call in `AppServiceProvider::boot()` covers every model. The skeleton's provider does not call it; strictness is opt-in. With it on, the next unloaded relation read as a property throws `Illuminate\Database\LazyLoadingViolationException`, whose message names the model and the relation, pointing straight at the missing `with()`. ## What it deliberately does not catch The check is narrower than "every lazy load", by design: - **Single-model results.** When the builder hydrates rows, it marks each model as lazy-load-protected **only if the result had more than one row**. `Post::find(1)->author` or `Post::first()->author` still lazy loads, because loading one relation for one model is not an N+1. - **New and just-created models.** Models built with `new` or `create()` are not hydrated from a query, so they carry no flag, and without a custom handler the violation path also returns quietly for a model that does not exist yet or was created in this request (`wasRecentlyCreated`). - **Relation method calls.** `$post->comments()->where(...)->get()` is an explicit query you asked for, not a lazy load, so it never trips the check, even inside a loop. - **Relations already loaded.** `with()`, `load()`, `loadMissing()` and `$with` set the relation, so reading it is free and allowed. - **Automatic eager loading.** If `Model::automaticallyEagerLoadRelationships()` is also on, Eloquent tries the autoloader first; when it loads the relation for the collection, no violation is raised. ## Production: throw, log, or skip The docs pattern disables the check in production so an eager load missed in review does not turn a slow page into a failing one. The cost is that production then hides the very N+1 you wanted to see. The middle path is a handler: ```php Model::preventLazyLoading(); Model::handleLazyLoadingViolationUsing(function (Model $model, string $relation) { if (app()->isProduction()) { logger()->warning('Lazy loaded relation', [ 'model' => $model::class, 'relation' => $relation, ]); return; } throw new \Illuminate\Database\LazyLoadingViolationException($model, $relation); }); ``` When a handler is registered, Eloquent calls it instead of throwing, and after it returns the relation is lazy loaded as usual. Tests and local runs still fail loudly; production records the violation and keeps serving. ## `shouldBeStrict()` bundles three checks | Called by `shouldBeStrict()` | Catches | |---|---| | `preventLazyLoading()` | an unloaded relation read as a property on a multi-row result | | `preventSilentlyDiscardingAttributes()` | mass assignment of keys that are not fillable, which are otherwise dropped silently | | `preventAccessingMissingAttributes()` | reading an attribute that was not selected, such as `email` after `with('author:id,name')`, via `MissingAttributeException` | `Model::shouldBeStrict(! $this->app->isProduction())` enables all three at once; each can still be toggled on its own. ## From exception to fix The exception names the model and the relation, for example `Attempted to lazy load [author] on model [App\Models\Post] but lazy loading is disabled.` The right fix depends on where the read happens: - **A loop in a view or controller**: add the relation to the query that built the collection, `Post::with('author')`. - **Shared code that may receive loaded or unloaded models**: `loadMissing('author')` on the collection it receives. - **A total shown per row**: an aggregate (`loadCount('comments')`) instead of reading the relation. - **A child reading back to its parent**: `chaperone()` on the parent's `hasMany`, so the inverse is set without a query. - **A relation needed only on some rows**: still load it for the collection; a batched query for all rows is cheaper than one per row that needs it. Resist the quick fix of calling `load()` inside the loop: it silences the exception while keeping one query per row. ## Rolling it out on an existing codebase 1. Enable it in local and testing environments first; the test suite becomes the list of violations. 2. Fix each by adding the relation to the query that produced the collection, not by calling `load()` inside the loop. 3. Add a production handler that logs, and watch for relations your tests never exercised. 4. Once the log stays quiet, decide whether production should throw too. The judgment interviewers look for is this trade: an exception is the fastest feedback, but a page that errors in production is worse than a page that is slow.
- With preventLazyLoading() on, why does Post::find(1)->author not throw?The builder only marks models as lazy-load-protected when the query hydrated more than one row. A single model loading one relation is one extra query, not a per-row pattern, so Eloquent lets it through. The same post read inside a collection of 20 would throw, because there the lazy load repeats per row.
- What happens after a handleLazyLoadingViolationUsing() callback returns without throwing?Eloquent continues and lazy loads the relation as if the check were off, so the page still works and still pays the extra query. The callback receives the model, the relation name and the prepared `LazyLoadingViolationException`, so it can log, report, or throw selectively, for example throwing everywhere except production.
- Does preventLazyLoading() catch $post->comments()->count() inside a Blade loop?No. Calling the relation method builds and runs an explicit query, which the check does not treat as a lazy load, so a per-row `count()` or `get()` on the method passes silently. Fix it with an aggregate such as `withCount('comments')` or `loadCount('comments')`, and watch query counts in tests rather than relying on the exception alone.
saying these in an interview costs you the question
- preventLazyLoading() throws on every lazy load, including Post::find(1)->author
- It has to be switched on separately inside each model's booted() method
- Relation method calls like $post->comments()->get() also throw when it is on
- With a violation handler registered, the relation is never loaded
- shouldBeStrict() only prevents lazy loading and nothing else
- Turning it on in production has no downside because tests caught everything