The N+1 That Survives Strict Mode
September 17, 2026 • 4 min read
You eager loaded the relationship. You turned on Model::shouldBeStrict() in development so an unloaded relationship throws instead of quietly querying. This loop still runs 52 queries, and nothing warns you:
$authors = Author::query()->with('posts')->get();
foreach ($authors as $author) {
$count = $author->posts()->count();
}The fixture below is 50 authors with 500 posts between them, half of them published. The counts are Eloquent’s rather than the database’s, so they come out the same whichever driver you’re on.
Four ways to write the same bug
$author->posts->where('published', true) 2 queries
$author->posts()->where('published', true)->get() 52 queries
$author->posts->count() 2 queries
$author->posts()->count() 52 queries
$author->posts->isNotEmpty() 2 queries
$author->posts()->exists() 52 queries
$author->posts->first() 2 queries
$author->posts()->first() 52 queriesEvery pair does the same job. The second of each runs one query per author on top of the two the eager load already paid for.
The count() pair is the one I’d bet on appearing in your codebase right now, because the two versions are two characters apart and both read as obviously correct.
Why the parentheses matter
$author->posts is a property. Eloquent hands you the collection that with('posts') already loaded, sitting in memory on the model.
$author->posts() is a method. It returns a fresh HasMany relation, built from the model’s foreign key and nothing else. That object has no idea an eager load ever happened, so count(), exists(), first() and get() on it each go to the database.
Both spellings are legitimate, which is the whole problem. Sometimes you genuinely want a fresh constrained query against a relationship, and posts() is how you ask for one.
The guardrails don’t see it
This is where it stops being an ordinary gotcha. Laravel has two features aimed squarely at N+1 problems, and neither one notices:
shouldBeStrict autoEager
$author->posts, never eager loaded exception 2 queries
$author->posts() after with('posts') 52 queries 52 queriesModel::shouldBeStrict() throws a LazyLoadingViolationException the moment you read a relationship that wasn’t loaded. Model::automaticallyEagerLoadRelationships() goes further and resolves that same case for you, turning the textbook N+1 into two queries without you touching the code.
Both of them hook the property path. They fire when Eloquent is asked for a relationship it doesn’t have in memory, because that is a decision the framework can recognise as a mistake.
Calling posts() isn’t that. You asked for a relation object and then ran a query on it, which is an ordinary, supported thing to do. Nothing in the call says whether you meant “fetch these again with a filter” or “I forgot I already had them”. The framework can’t tell the difference, so it doesn’t try, and the strictest setting Laravel offers stays silent through all 52 queries.
That’s worth being clear about: this isn’t a hole in strict mode. It’s outside what strict mode is able to claim.
What to use instead
Reading data you already loaded is the property, every time. $author->posts->count(), $author->posts->first(), $author->posts->isNotEmpty().
Counts you don’t need the rows for belong in withCount(), which resolves the whole thing in a single query:
Author::query()->withCount('posts')->get(); // 1 queryA filtered subset belongs in the eager load rather than in the loop. Filtering the loaded collection in memory gets you back to two queries, but it still hydrates every post and discards most of them. Constraining the eager load does the filtering in SQL:
Author::query()
->with(['posts' => fn ($query) => $query->where('published', true)])
->get();unconstrained with('posts'), filtered in PHP 500 rows hydrated
constrained eager load 250 rows hydratedA genuinely fresh query is the one case posts() is right for - a different filter, a different order, something the eager load couldn’t have known about. Just know you’re paying for a query when you write it.
Finding them
There’s no setting for this, so it comes down to reading. Search for ->posts()->, or whatever your relationships are called, and check each hit against the query that loaded the parent. Anything with a with() above it and a () below it is either deliberate or a bug, and the code doesn’t record which.
A query counter in local development is the blunter instrument and catches more. Laravel Debugbar or Telescope will show 52 where you expected 2, which is the signal strict mode can’t give you.