A page loads fine in development with twelve records and crawls in production with twelve hundred. Nothing changed in the code. What changed is how many times one query inside a loop ran.
What an N+1 actually is
You fetch a list, then touch a relation on each item:
$projects = Project::all(); // 1 query
foreach ($projects as $project) {
echo $project->client->name; // 1 query — each time round
}
Twelve projects is 13 queries. Twelve hundred is 1,201. The code reads perfectly well, which is why this survives review.
Seeing it
Count queries rather than guessing. Log them in a local service provider:
// AppServiceProvider::boot(), local only
if (app()->environment('local')) {
DB::listen(fn ($q) => logger($q->sql));
}
Load the page and count the lines. If one statement repeats with only the id changing, that is your N+1. Laravel Debugbar and Telescope show the same thing with less setup.
Make Laravel refuse to do it
Better than finding them by hand — have the framework throw when a relation loads lazily:
// AppServiceProvider::boot()
Model::preventLazyLoading(! app()->isProduction());
Now an N+1 fails loudly in development instead of quietly in production. Turn it on in an existing project and expect to find several immediately.
The fix
$projects = Project::with('client')->get(); // 2 queries, whatever the count
Laravel fetches every client in one whereIn and matches them up. Two queries for twelve rows, and two for twelve hundred.
Nested and multiple relations
Project::with(['client', 'tasks.assignee'])->get();
Load only the columns you use
Project::with('client:id,name')->get();
Include the foreign key — leave id out and the relation cannot be matched, and the relation comes back null with no error.
Counting without loading
To show how many tasks a project has, do not load the tasks:
$projects = Project::withCount('tasks')->get();
// $project->tasks_count
One aggregate query instead of pulling every row into memory to call count() on it.
When eager loading is the wrong fix
Eager loading trades queries for memory. Loading a relation for 50,000 rows to display ten of them just moves the problem. In that case paginate first, or select the few columns you need with a join.
Chunk long-running jobs rather than eager loading everything:
Project::with('client')->chunk(200, function ($projects) {
// 200 at a time, memory stays flat
});
Keeping them out
- Turn on
preventLazyLoadingoutside production - Check the query count when a page feels slow, before optimising anything else
- Eager load in the controller, not the view — a view that triggers queries hides them
- Use
withCountfor totals
Most "Laravel is slow" reports are one of these. Fix the query count first; if it is still slow after that, then look at indexes.