An eloquent n+1 problem can turn a 40-millisecond page into a 4-second one with a single unguarded loop. In production systems I've tuned, the pattern is always the same: code that passes review, then collapses the first time it meets real data volume. The 100M-Row Challenge with PHP made this explicit, the difference between a working application and a dead one is usually query discipline, not raw framework speed. This guide shows how to detect n+1 queries before they ship, fix them with eager loading, and handle datasets too large for get() using chunking, queues, and caching.
Most Laravel performance incidents trace back to three habits: lazy loading in loops, loading full columns you never read, and pulling entire tables into memory. Every fix in this article targets one of those three.
What Is the Eloquent N+1 Problem?
An eloquent n+1 issue occurs when you fetch a collection, then access a relationship on each model inside a loop. Eloquent lazy-loads the relationship per model, so 100 posts become 101 queries: one for the posts, one hundred for their authors.
// The classic accident: 1 + N queries
$posts = Post::all();
foreach ($posts as $post) {
echo $post->user->name; // triggers a query per iteration
}Each individual query is fast, which is why the problem survives local development. On production traffic with hundreds of concurrent requests, those queries multiply against your connection pool and PostgreSQL's working set. The fix is to tell Eloquent up front which relationships you need, so it fetches them in one additional query using an WHERE IN clause.
How Do You Detect Eloquent N+1 Before It Ships?
Make lazy loading throw instead of silently degrading. Laravel ships this as a one-liner in your AppServiceProvider:
// app/Providers/AppServiceProvider.php
public function boot(): void
{
Model::shouldBeStrict(! app()->isProduction());
// In production, log instead of failing:
Model::handleLazyLoadingViolationUsing(function ($model, $relation) {
Log::warning('Lazy loading detected', [
'model' => $model::class,
'relation' => $relation,
]);
});
}shouldBeStrict() also prevents silently discarding attributes and accesses to missing attributes, three failure modes caught at the first test run. Pair this with Laravel Telescope or the query log in your test suite: assert query counts in feature tests so a regression fails CI rather than your pager.
Add DB::whenQueryingForLongerThan(500, ...) in a middleware during load testing. It fires only when cumulative query time on a request crosses the threshold, which surfaces n+1 patterns that per-query slow logs miss.
Which Eager Loading Strategy Fits Your Query?
Eager loading is not one tool but four, and choosing wrong costs memory. Use with() when you know you'll need the relation, load() for decisions made after the initial fetch, constrained eager loads to cap related rows, and withCount() when you only need a number. Constrain columns on both parent and child, fetching SELECT * on a wide table to render a title is wasted I/O.
| Strategy | Queries | Memory | Use When |
|---|---|---|---|
Lazy ($post->user) | 1 + N | Low | Never in loops |
with('user') | 2 | High (all rows) | You access most relations |
Constrained with + limit | 2 | Medium | Latest 5 comments per post |
withCount('comments') | 2 | Low | You only display a count |
$posts = Post::query()
->select('id', 'user_id', 'title')
->with(['comments' => fn ($q) => $q
->select('id', 'post_id', 'body', 'created_at')
->latest()
->limit(5)])
->withCount('comments')
->get();The official eager loading docs cover nested loading with dot syntax, with('comments.author'), which prevents the same problem one level deeper.
How Do You Process Millions of Rows Without Exhausting Memory?
Eager loading fixes query count, not memory. Post::all() on a million-row table hydrates a million models before your first iteration. For batch work, Laravel gives you three escape hatches, and the right one depends on whether you need updates and model events.
| Method | Memory | Queries | Best For |
|---|---|---|---|
get() | Entire result set | 1 | Small, bounded sets |
chunkById(1000, ...) | One page | N/1000 | Updating rows while iterating |
lazyById(1000) | One page (LazyCollection) | N/1000 | Read-only pipelines with map/filter |
cursor() | One row | 1 (buffered) | Exports over a single stable connection |
Always use the ById variants when writing to the table you're iterating, offset-based chunk() skips rows when the result set shifts mid-scan. For anything slow, push the work into a laravel queue instead of holding the request open: dispatch job IDs from the chunk, let workers handle retries and backoff.
Order::where('status', 'pending')
->chunkById(1000, function ($orders) {
foreach ($orders as $order) {
SettleOrder::dispatch($order->id); // laravel queue worker picks it up
}
});cursor() holds one PostgreSQL connection for the entire iteration. On a queue worker with a 60-second timeout, a long cursor scan can outlive the connection, set generous timeouts or use lazyById instead.
Where Do Laravel Caching and Octane Fit In?
Fixing the query is step one; not running the query is step two. Laravel caching with Cache::remember() absorbs repeated aggregate reads, dashboards, dropdown options, config-like data, so the same expensive query doesn't run per request. Tag or version your keys so invalidation stays predictable.
$summary = Cache::remember('orders:summary:v1', now()->addMinutes(10), function () {
return Order::query()
->selectRaw('status, count(*) as total')
->groupBy('status')
->pluck('total', 'status');
});Laravel octane changes the failure mode rather than removing it. By keeping workers resident in memory, Octane eliminates framework boot cost, but static state, singletons holding request data, and lazy-loading violations now leak across requests instead of dying with them. Run your strict-mode checks under Octane in staging, not just under FPM.
Laravel Best Practices for Query Hygiene
The blog archive covers architecture at length; these are the query-level habits that keep an application fast as data grows, drawn from established laravel best practices:
- Index every foreign key you eager load,
WHERE INon an unindexed column is an n+1 fix that trades latency for a sequential scan. - Select explicit columns on parents and relations; hydration cost scales with column count.
- Use
withExists()instead ofwithCount()when you only need a boolean. - Never call
->count()or->exists()inside a loop, batch it withwithCountor one aggregate query. - Assert query counts in feature tests; treat a count regression like a failing assertion.
My own production work at JB Connect and previous roles follows the same rule: measure with EXPLAIN ANALYZE before optimizing, and re-measure after.
Frequently Asked Questions
Does eager loading always beat lazy loading?
No. If you access the relation on 2 out of 500 models, lazy loading runs 2 queries while eager loading runs 2 queries but hydrates 500 related models. Eager loading wins when access is dense; lazy loading is acceptable when access is rare and bounded.
What's the difference between chunk and cursor?
chunk() runs one query per page and hydrates a page of models. cursor() streams one row at a time over a single query using a buffered connection. Chunk is safer for writes and long jobs; cursor uses the least memory for read-only exports.
How many queries per request is too many?
There's no universal number, but anything growing with collection size is a defect. A stable request should issue a bounded query count regardless of how many rows the underlying tables contain.
Can I fix n+1 without changing application code?
Partially. Recursive CTEs or JSON aggregation in PostgreSQL collapse the queries server-side, but you lose Eloquent hydration and relations. For most Laravel codebases, with() plus column selection is simpler and sufficient.