The N+1 you can't see (it's hiding in your accessors)
Published June 20, 2026 · by Majd Ghithan, written with the help of AI
I've fixed hundreds of N+1 queries. The easy ones are right there in the controller — a foreach with $order->customer inside it, obvious the moment you read the code. Those aren't the ones that hurt.
The ones that hurt are the N+1s you can't see by reading the controller, because the query fires somewhere you'd never look: inside an accessor, inside an API Resource, inside a policy check, inside a Blade partial. The controller looks clean. The query count is 300. And nobody can find why, because the loop that causes it isn't written anywhere near the code that triggers it.
Here are the four hiding spots, and the one setting that drags them all into the light.
1. The accessor that loads a relation
This is the sneakiest one. Someone adds a "convenience" accessor on the model:
// User model
public function getIsPremiumAttribute(): bool
{
return $this->subscription->plan === 'premium';
}
Reads great. $user->is_premium — clean. But $this->subscription is a relation, and if it wasn't eager-loaded, every access to is_premium fires a query. Now put $user->is_premium in a table of 200 users, and you have 200 queries generated by a property that looks like a plain boolean. Nothing in the loop mentions a query. The query is hidden one layer down, inside the accessor.
2. The API Resource that reaches for relations
// OrderResource
public function toArray($request): array
{
return [
'id' => $this->id,
'total' => $this->total,
'customer_name' => $this->customer->name, // relation
'item_count' => $this->items->count(), // relation
];
}
OrderResource::collection($orders) maps over every order and builds this array for each one. Each $this->customer and $this->items that wasn't eager-loaded is a query — per order. The controller just says return OrderResource::collection($orders), one clean line. The N+1 lives inside the Resource, invisible from the call site.
3. The policy that queries on every check
public function view(User $user, Document $doc): bool
{
return $doc->team->members->contains($user); // two relations
}
Now render a list of 50 documents and @can('view', $doc) in the Blade loop. The policy runs 50 times, and each run walks team then members — relations that weren't loaded because you were only thinking about the documents, not the authorization. A hundred queries generated by a permission check.
4. Lazy relations in a Blade partial
The controller passes $posts to the view. The view includes _post_card.blade.php per post, and that partial renders {{ $post->author->name }}. The N+1 isn't in your controller or even your main template — it's buried in a partial two includes deep, and it fires once per card.
The compare that explains all four
$orders = Order::all();
// somewhere else — resource, accessor,
// policy, or partial:
$o->customer->name; // query, every time
// 200 orders = 200 hidden queries
$orders = Order::with('customer')->get();
// same access, now free:
$o->customer->name; // no query — preloaded
// 200 orders = 2 queries total
The fix is always the same — eager-load the relation with with(). The hard part was never the fix. It's finding that a relation is being touched at all, when the touch is buried three layers away from the query count.
Stop hunting. Make Eloquent refuse to lazy-load.
This is the setting that changes everything. In AppServiceProvider::boot():
use Illuminate\Database\Eloquent\Model;
public function boot(): void
{
Model::preventLazyLoading(! app()->isProduction());
}
Now, in local and staging, the instant any code lazy-loads a relation that wasn't eager-loaded, Laravel throws a LazyLoadingViolationException — and it tells you exactly which model and which relation. The accessor, the Resource, the policy, the partial: whichever one reaches for an unloaded relation blows up loudly at development time, pointing straight at the line.
You don't go looking for invisible N+1s anymore. They announce themselves the first time you hit the page. The ones that used to survive all the way to production — because 200 fast queries never actually errors, it just quietly costs you 400ms — now can't get past your local environment.
Note the ! app()->isProduction() guard: you want this loud in development and off in production, so a violation you missed degrades to a slow query rather than a 500 for a real user. Turn it on, run your app once, and fix every exception it throws. What's left is an app where the query count is honest — where reading the controller actually tells you how many queries the page runs, because there's nothing hiding a layer down anymore.
