The page takes four seconds. The slow query log is empty, because none of the queries are slow. There are just eight hundred of them.
This is the most common performance bug in any Eloquent codebase, and it hides in the one place nobody looks, which is code that reads exactly like what you meant.
The shape of it
$leads = Lead::latest()->limit(100)->get(); // 1 query
foreach ($leads as $lead) {
echo $lead->assignee->name; // 1 query. Each time. 100 total.
}101 queries, each fast, together slow. Round-trip latency, connection overhead and PHP's own per-result work dominate, and none of it shows up in a slow query log that thresholds on duration.
It compounds quietly too. $lead->assignee->team->name inside that same loop is another hundred queries, and nothing in the source makes the difference visible.
Make it impossible to miss
The fix for the class of bug is not an eager load. It is making lazy loading loud.
use Illuminate\Database\Eloquent\Model;
public function boot(): void
{
// Local and CI only: lazy loading now throws instead of quietly costing you.
Model::preventLazyLoading(! $this->app->isProduction());
}Now $lead->assignee inside a loop raises LazyLoadingViolationException the moment you write it, naming the model and the relation. You find the bug at the keyboard rather than in a graph three weeks later, which is a difference of about two orders of magnitude in what it costs to fix.
Pair it with a query count you can see:
DB::listen(fn ($q) => app('queries')->increment());
// then log it per request, and fail a test above a thresholdA page whose query count rises with the number of rows rendered is N+1 by definition. That is a property you can test once and keep forever.
Fix it by stating what the page needs
$leads = Lead::with(['assignee:id,name', 'source:id,label'])
->latest()
->limit(100)
->get(); // 3 queries, regardless of rowsThree queries whether there are ten rows or ten thousand.
Two details earn their place there. The column list in assignee:id,name loads two columns rather than a whole user row, and the foreign key has to be in that list or the relation cannot be matched back, which is a five-minute confusion the first time it happens.
And with versus load: with eager loads as part of the original query, for when you know up front what the page needs. load does the same thing to a collection you already have, for when the decision depends on something you only learned after fetching.
For counts, do not load rows in order to count them:
$leads = Lead::withCount('activities')->get(); // activities_count, no rows loadedAnd for "the latest related record per row", which is otherwise a loop by another name:
$leads = Lead::with('latestActivity')->get(); // via a HasOne ofMany relationEager loading is not free either
The opposite mistake shows up within about a week of fixing the first one.
$leads = Lead::with(['assignee', 'source', 'activities', 'documents', 'notes'])->get();Five relations loaded for a list view that renders two of them. You are now pulling thousands of rows into PHP memory in order to display none of them, and a well-intentioned fix has turned into a different performance problem that is harder to spot, because the query count looks great.
Load what the view renders. If two views need different relations, let each one decide. A model that eager loads globally through $with is convenient right up until a page needs none of them, and then it is convenient for nobody.
Then look at the three queries you have left
Once the count is down from eight hundred to three, the duration of those three starts to matter, and that is a different exercise. Read the plan, confirm the indexes exist, and check that the eager-load query is not itself doing a full scan. The method is in reading a slow MySQL query plan.
Order matters here. Tuning an index while a page issues a query per row is optimising the wrong number entirely, in the same way that compressing images does nothing for a page whose LCP element is text.
Keeping it fixed
Three habits, all cheap.
preventLazyLoading on in local and CI, because the exception is the whole mechanism and everything else here is downstream of it.
Assert query counts in feature tests for any page that renders a list. Laravel gives you expectsDatabaseQueryCount(), called at the top of the test, and the test fails if the real count differs. A DB::listen counter with a cap works too. Either way the number stops drifting quietly.
And read the count in review. "How many queries does this page issue now" is a better review question than "does this look efficient", because the first one has an answer and the second one invites a shrug.
Why it survives so long
N+1 is not a subtle problem, it is an invisible one. The loop reads naturally, every individual query is genuinely fast, and the cost only appears at a scale nobody develops against.
So make it visible. Throw on lazy loading outside production, count queries per request, eager load exactly the relations the page renders.
After that the fix is one line, and more importantly it stays fixed, because the next instance announces itself the moment someone writes it.