Catch N+1 Queries Early with Eloquent Strict Mode

Eloquent strict mode makes Laravel throw on lazy loading and N+1 queries in development, so performance bugs surface long before they reach production.

Steven Richardson
Steven Richardson
· 6 min read

An N+1 query rarely announces itself. It ships green, passes every test, and turns up three weeks later as a p95 latency spike nobody can reproduce locally. Eloquent strict mode flips that around: instead of silently firing a hundred extra queries, your app throws an exception the moment a relationship is lazy loaded in development — so the bug fails the build instead of the customer.

Enable Eloquent strict mode in a service provider#

Eloquent strict mode is one line in the boot() method of your AppServiceProvider. Model::shouldBeStrict() bundles three guards in a single call: it prevents lazy loading, throws when you mass-assign an attribute that isn't fillable, and throws when you read an attribute that was never loaded from the database. Add it once and every model in the app inherits the behaviour.

<?php

namespace App\Providers;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\ServiceProvider;

class AppServiceProvider extends ServiceProvider
{
    public function boot(): void
    {
        Model::shouldBeStrict();
    }
}

One wrinkle worth knowing: shouldBeStrict() was quietly dropped from the Laravel docs back in the 10.x cycle, but it's still in the framework and still does exactly this. If you'd rather stick to documented APIs, skip to the individual guards near the end of this article.

Restrict strict mode to non-production environments#

You never want these exceptions reaching real users. A lazy-loaded relationship in production should degrade to one extra query, not a 500. shouldBeStrict() takes an optional boolean, so gate it on the environment — strict everywhere except production.

public function boot(): void
{
    // Strict in local, testing, and staging — relaxed in production.
    Model::shouldBeStrict(! $this->app->isProduction());
}

isProduction() checks whether APP_ENV is production. One caveat: if you set APP_ENV=production on staging too (plenty of teams do), this also relaxes strict mode there. In that case, gate on a separate flag — something like config('app.strict_models') driven by its own environment variable — so staging keeps throwing while production stays quiet.

Reproduce a lazy loading violation and read the exception#

Here's the footgun strict mode catches. Loop over a collection and touch a relationship you didn't eager load, and Eloquent fires one query per row — the classic N+1.

// A controller action or route closure.
$articles = Article::all();

foreach ($articles as $article) {
    // Lazy load — one extra query per article.
    echo $article->author->name;
}

With strict mode on you don't get the slow page — you get an exception the instant the first lazy load happens:

Illuminate\Database\LazyLoadingViolationException:
Attempted to lazy load [author] on model [App\Models\Article] but lazy loading is disabled.

The message names both the relation (author) and the model (App\Models\Article), so you know precisely where to add the eager load. That's the whole value: N+1 detection moves from a production slow-query log to a stack trace on your screen.

Fix the N+1 with eager loading#

The fix is eager loading: tell Eloquent which relationships you need up front and it fetches them in one extra query using a single whereIn, no matter how many rows come back.

// Two queries total, regardless of how many articles.
$articles = Article::with('author')->get();

foreach ($articles as $article) {
    echo $article->author->name; // already loaded, no extra query
}

For nested relationships, use dot syntax — with('author.team'). If you already have a model in memory and need to add a relationship, reach for load() instead of with(). This is the same discipline you apply when serialising relationships in Laravel 13 JSON:API resources, which are a classic place for N+1 to creep in unnoticed.

Opt into individual strictness guards as needed#

shouldBeStrict() is all-or-nothing. On a legacy codebase that's often too much at once — you'll drown in missing-attribute exceptions before you've fixed a single N+1. Enable the three guards individually so you can tighten one screw at a time.

public function boot(): void
{
    $strict = ! $this->app->isProduction();

    // Catch N+1 queries (documented).
    Model::preventLazyLoading($strict);

    // Throw when mass-assigning a non-fillable attribute (documented).
    Model::preventSilentlyDiscardingAttributes($strict);

    // Throw when reading an attribute that was never selected/loaded.
    Model::preventAccessingMissingAttributes($strict);
}

preventLazyLoading and preventSilentlyDiscardingAttributes are both in the current docs; preventAccessingMissingAttributes is the third guard shouldBeStrict() adds and the one most likely to bite an existing app, so introduce it last. If you'd rather keep watching for N+1 in production without throwing, leave lazy-loading prevention on everywhere and register a handler that logs instead:

Model::preventLazyLoading();

Model::handleLazyLoadingViolationUsing(function (Model $model, string $relation): void {
    logger()->warning('Lazy loaded ['.$relation.'] on ['.$model::class.'].');
});

That gives you the best of both worlds — you hear about N+1 in production without 500-ing anyone. On a high-traffic app, sample those reports (a simple 1-in-N lottery) so you don't flood your logs with the same violation.

Gotchas and Edge Cases#

A few things will trip you up the first time you turn this on.

Your test suite will start failing. The moment strict mode goes on, every test that quietly relied on lazy loading throws. That's the feature working — but budget a cleanup pass and treat it like enabling a linter for the first time. It's also the best argument for running strict mode in CI: a GitHub Actions matrix build across PHP and database versions will catch a lazy-load regression before it ever reaches a reviewer.

preventAccessingMissingAttributes and select() don't mix gently. If you fetch a subset of columns with select(['id', 'title']) and then read $model->status, strict mode throws because status was never loaded. That's technically correct, but it surprises people — either select the column you need or scope that guard off the path that does partial selects.

Strict mode is a floor, not a profiler. It flags lazy loading; it does not flag over-eager loading. You can still call with('comments') and never touch comments, wasting a query. Pair strict mode with an assertQueryCountLessThan() style assertion in tests, or Telescope locally, when you want the full picture.

Don't confuse lazy loading with lazy collections. Eloquent's lazy loading is the N+1 trap this article is about. Lazy collections are a deliberate memory optimisation for streaming large datasets like CSV imports — strict mode has no quarrel with those.

Wrapping Up#

Drop Model::shouldBeStrict(! $this->app->isProduction()) into AppServiceProvider on your next project and N+1 queries stop being a production mystery — they become a failed test you fix in seconds. On an existing app, introduce the guards one at a time and set aside a session to clear the backlog.

N+1 also loves to hide inside queued jobs that loop over models, so enable strict mode in the same environments where your workers run — it pairs naturally with the observability you put in place when scaling Laravel queues in production.

FAQ#

What does Model::shouldBeStrict() do in Laravel?

Model::shouldBeStrict() enables three Eloquent guards at once: it prevents lazy loading of relationships, throws when you mass-assign an attribute that isn't in the model's fillable array, and throws when you access an attribute that wasn't loaded from the database. It's a single call placed in AppServiceProvider::boot() that makes silent footguns loud during development. The method is still in the framework even though it was removed from the official documentation during the Laravel 10 cycle.

How do I detect N+1 queries in Laravel?

The fastest way is to enable lazy-loading prevention with Model::preventLazyLoading() (or the broader Model::shouldBeStrict()) in a non-production environment. Eloquent then throws a LazyLoadingViolationException the moment a relationship is lazily loaded, and the message tells you which relation and model caused it. For ongoing monitoring you can pair that with a query-count assertion in your tests or a tool like Laravel Telescope to inspect query volume per request.

Should I enable Eloquent strict mode in production?

Generally no — you don't want a stray lazy load turning into a 500 for real users. Gate strict mode behind ! $this->app->isProduction() so it only throws in local, testing, and staging. If you do want production signal, keep preventLazyLoading() on everywhere but register handleLazyLoadingViolationUsing() to log or report the violation instead of throwing, and sample those reports on a high-traffic app.

What is the difference between preventLazyLoading and preventAccessingMissingAttributes?

preventLazyLoading targets N+1 queries: it throws when you access a relationship that wasn't eager loaded. preventAccessingMissingAttributes targets a different bug: it throws when you read a model attribute that was never loaded from the database — for example after a partial select() or when you typo a column name. shouldBeStrict() turns on both (plus preventSilentlyDiscardingAttributes), but only preventLazyLoading is about query performance.

How do I fix a lazy loading violation exception?

Eager load the relationship named in the exception. If the message says Attempted to lazy load [author], change Article::all() to Article::with('author')->get() so the relation is fetched up front in a single extra query. For relationships you need on a model that's already in memory, call $model->load('author') instead. For nested relations, use dot syntax such as with('author.team').

Steven Richardson
Steven Richardson

CTO at Digitonic. Writing about Laravel, architecture, and the craft of leading software teams from the west coast of Scotland.