Find N+1 queries in your Laravel app — automatically
Lookout flags N+1 queries in Laravel without any setup. Here's how to spot the flagged issue, read the repeated query, and fix it with eager loading — on the free tier.
The N+1 query is the most common performance bug in Laravel, and the most invisible. Everything works in development with ten rows. Then production gets a thousand rows, the same query runs a thousand times, and the page that felt instant now takes four seconds. Nobody changed any code.
If you've already got lookout/tracing installed (composer require lookout/tracing, then php artisan lookout:install), the good news is you don't have to go looking — Lookout flags N+1 patterns on its own. This is how to read what it finds and fix it.
What an N+1 actually is
You load a list of records, then touch a relationship inside a loop:
$posts = Post::all(); // 1 query
foreach ($posts as $post) {
echo $post->author->name; // +1 query per post
}
One query to fetch the posts, then one more for every post's author. Ten posts, eleven queries. A thousand posts, a thousand and one. That's the "N+1": one query plus N follow-ups that should have been folded into the first.
Why they hide in development
N+1s are sneaky precisely because they pass every check that runs on small data. Eloquent's relationships use lazy loading — $post->author looks like reading a property, but it quietly runs a query the first time you touch it. With ten seed rows, the extra queries are imperceptible; your tests pass, the page feels instant, code review sees nothing wrong. The bug only wakes up at production scale, which is exactly when you don't want to be discovering it. That's the case for letting something watch production continuously rather than auditing by hand.
How Lookout surfaces it
You don't configure anything for this. As requests come through, Lookout watches the queries each one runs and flags requests where the same query shape repeats many times with only the bound values changing. The flagged issue shows you:
- the repeated query (normalized, so the thousand copies collapse into one),
- how many times it ran in that single request,
- and the request it happened on, so you know which page to fix.
That count is the tell. A normal page runs a handful of distinct queries. An N+1 shows one query with a multiplier next to it.
Fixing it with eager loading
Once Lookout points at the relationship, the fix is almost always eager loading — tell Eloquent to fetch the relationship up front instead of lazily in the loop:
$posts = Post::with('author')->get(); // 2 queries total, regardless of N
foreach ($posts as $post) {
echo $post->author->name; // no extra query
}
Two queries instead of a thousand-and-one. For nested relationships, chain them:
$posts = Post::with(['author', 'comments.user'])->get();
If a relationship is used on most reads, you can also default it on the model with protected $with = ['author']; — just be deliberate, because that loads it everywhere.
The other places N+1s sneak in
Eager loading a top-level relationship is the common case, but the pattern shows up in a few less obvious spots:
- Counting related rows.
$post->comments->count()loads every comment just to count them. UsewithCount('comments')and read$post->comments_count— one query, no hydration. - Accessors that query. A model accessor that touches a relationship turns every read of that attribute into a query. Eager-load what the accessor needs.
- Nested loops in Blade. A loop over a relationship nested inside another loop is an N+1 generator. Eager-load the nested relation up front (
with('comments.user')). - API resources. A resource that serializes a relationship per item will N+1 unless the relation was eager-loaded on the underlying collection.
When eager loading isn't the whole answer
Occasionally the fix is something other than with():
- If you only need a couple of columns, pair eager loading with a constrained select to avoid hauling whole rows across.
- For very large result sets,
chunk()or lazy collections keep memory flat — but make sure you eager-load inside the chunk so you don't reintroduce the N+1. - If the same relationship is read repeatedly across requests and rarely changes, caching the lookup can beat re-querying it at all.
Confirm the fix
Deploy, hit the same page, and look at the issue again. The repeated-query count drops to one, and the request's query total falls off a cliff. That before/after is the satisfying part — and the reason to let the tool watch continuously rather than auditing by hand once a quarter.
Stop them coming back
N+1s creep back in every time someone adds an innocent ->relationship inside a loop. Two habits keep them out:
- Fail loud in development. Laravel's
Model::preventLazyLoading()(in a non-production service provider) turns an accidental lazy load into an exception while you're developing, so you catch the N+1 before it ships. - Watch production. For the ones that slip through anyway, having something flag the pattern on real traffic is the cheap insurance.
Lookout's free Starter tier — 10,000 events a month, one project, no credit card — is enough to put a real app under it and see what surfaces. For the bigger picture, the complete guide to Laravel error tracking ties N+1 detection together with grouping, slow queries, and alerting.
Create your free project and find the N+1 that's already slowing down your busiest page.