Most Laravel Eloquent query bottlenecks are not caused by Eloquent being inherently slow. They happen because Eloquent makes expensive database behavior feel cheap.
That is the trap.
A relationship property looks like normal object access. A nested whereHas() reads like clean business logic. A big with() call feels like a safe optimization. Then traffic rises, queue workers back up, database CPU climbs, and suddenly the slowest part of your app is the code that looked the most elegant in review.
The practical takeaway is simple: treat query shape as part of endpoint design. If a route is hot, the SQL it generates is part of the feature, not an implementation detail you can ignore until production hurts.
The first bottleneck is usually query multiplication
Most Laravel apps do not fall over because of one absurd query. They slow down because one request quietly runs too many “reasonable” ones.
The classic example still matters because it keeps happening:
$posts = Post::latest()->take(20)->get();
foreach ($posts as $post) {
echo $post->author->name;
echo $post->category->title;
echo $post->comments->count();
}
That code is readable. It is also expensive.
You start with one query for posts, then trigger more queries for authors, categories, and comments. That is the familiar N+1 query problem, but the real production version is usually broader. Query multiplication leaks into places teams forget to inspect:
- Blade templates
- API resources
- model accessors
- policies and gates
- collection transforms
- helper methods touching relations indirectly
- notification builders
That is why “we fixed the controller” often does not fix the route.
A better baseline is to shape the data intentionally:
$posts = Post::query()
->select(['id', 'title', 'author_id', 'category_id', 'published_at'])
->with([
'author:id,name',
'category:id,title',
])
->withCount('comments')
->latest()
->take(20)
->get();
That change improves performance in three direct ways:
- narrower selected columns
- intentional relationship loading
- SQL-side counting instead of hydrating full collections
These are small-looking changes in PHP and meaningful changes under load.
Make lazy loading fail early
Laravel already gives you a solid guardrail here, and most teams should enable it outside production. The official relationship docs are here: https://laravel.com/docs/eloquent-relationships.
use Illuminate\Database\Eloquent\Model;
public function boot(): void
{
Model::preventLazyLoading(! app()->isProduction());
}
This will not solve every performance problem. It will catch a lot of accidental query creep before traffic does it for you.
Eager loading fixes one problem and often creates another
A lot of Laravel advice stops at “use eager loading.” That advice is incomplete.
Yes, eager loading fixes many N+1 issues. But blind eager loading often replaces query-count waste with data-volume waste.
This is a common overcorrection:
$orders = Order::with([
'user',
'items.product.images',
'coupon',
'shippingAddress',
'billingAddress',
'payments',
'refunds',
'events',
])->latest()->paginate(50);
The query count may improve. The endpoint can still be slow because it is loading far more data than the request actually needs.
That creates a different failure profile:
| Symptom | What is actually happening |
|---|---|
| high memory usage | too many related models hydrated into PHP |
| slow API serialization | resources walking oversized object graphs |
| database pressure | relation fetches are wider than the screen needs |
| weak throughput | each request carries too much unnecessary data |
This is where teams need a stricter rule: list views are not detail views.
If the endpoint is an orders index, the UI probably needs:
- order id
- customer name
- status
- total
- maybe an item count
It probably does not need full refund history, deep product image trees, payment logs, and event timelines for every row.
A healthier list query usually looks more like this:
$orders = Order::query()
->select(['id', 'user_id', 'status', 'total', 'created_at'])
->with(['user:id,name'])
->withCount('items')
->latest()
->paginate(50);
That is not premature optimization. It is basic endpoint discipline.
My recommendation is blunt because teams delay this too long: make query shape endpoint-specific by default. Reusing one oversized relation graph across pages, APIs, exports, and dashboards is convenient for developers and expensive for the system.
The worst slowdowns are usually SQL-shape problems disguised as elegant Eloquent
Once your app grows beyond simple CRUD, the nastiest bottlenecks are often not classic N+1 cases. They are expressive Eloquent queries that generate expensive SQL plans.
The usual suspects are predictable:
- nested
whereHas()chains - broad
orWhereHas()filters - sorting by related-table columns
- polymorphic filters on large tables
- repeated aggregate subqueries in paginated endpoints
- dashboards built directly on transactional tables
For example:
$users = User::query()
->whereHas('orders.items.product', function ($query) {
$query->where('is_active', true);
})
->whereHas('subscriptions', function ($query) {
$query->where('status', 'active');
})
->latest()
->paginate(25);
In PHP, this looks elegant. In SQL, it may be far more expensive than it appears.
This is one of the biggest ORM traps in Laravel. Because Eloquent can express something cleanly, teams assume the database can execute it efficiently. That assumption fails all the time.
When query logic gets deep, stop reasoning from the PHP outward. Inspect the real SQL and the real execution plan.
Useful tools include:
- Laravel Telescope for query visibility: https://laravel.com/docs/telescope
- Laravel Debugbar in development
- slow query logs
-
EXPLAINorEXPLAIN ANALYZE - APM traces if you have them
Sometimes the fix is still an Eloquent refactor. Sometimes it is a join. Sometimes it is a summary table or a dedicated read model. If the endpoint behaves like reporting, stop pretending it is ordinary CRUD.
Example: when the view quietly becomes the query planner
Suppose an admin dashboard shows recent invoices with:
- customer name
- item count
- latest payment status
- overdue state
A typical first version looks like this:
$invoices = Invoice::latest()->take(100)->get();
return view('dashboard', compact('invoices'));
Then the Blade template does this:
{{ $invoice->customer->name }}
{{ $invoice->items->count() }}
{{ optional($invoice->payments->last())->status }}
{{ $invoice->due_date->isPast() ? 'Overdue' : 'On time' }}
Readable, yes. Efficient, no.
This is exactly how query cost gets hidden in mature Laravel apps. The view is now implicitly deciding the workload.
A better version makes the data contract explicit:
$invoices = Invoice::query()
->select(['id', 'customer_id', 'due_date', 'created_at'])
->with(['customer:id,name'])
->withCount('items')
->with(['latestPayment:id,invoice_id,status,created_at'])
->latest()
->take(100)
->get();
And define a targeted relationship on the model:
public function latestPayment()
{
return $this->hasOne(Payment::class)->latestOfMany();
}
That is the pattern worth repeating. The view should consume shaped data, not accidentally define database work.
Counts, sums, and existence checks are quiet performance killers
Another common Eloquent bottleneck is loading full relation collections just to answer tiny questions.
This happens everywhere because it is convenient and often slips through code review without comment.
Bad:
$projects = Project::with('tasks')->get();
foreach ($projects as $project) {
echo $project->tasks->count();
}
Better:
$projects = Project::withCount('tasks')->get();
Bad:
if ($user->orders->count() > 0) {
// ...
}
Better:
if ($user->orders()->exists()) {
// ...
}
Bad:
$total = $user->payments->sum('amount');
Better:
$total = $user->payments()->sum('amount');
The rule is easy to remember: if you need a boolean, count, sum, or latest row, do that work in SQL.
Hydrating full collections just to derive a tiny answer is self-inflicted load, and on hot endpoints it adds up quickly.
Many Eloquent bottlenecks are really indexing problems
A surprising amount of slowness blamed on Eloquent is actually weak schema support.
The query may be logically fine. The database still struggles because there is no efficient access path.
This usually shows up in ordinary access patterns:
- filtering by
workspace_idortenant_id - filtering by
status - sorting by
created_at - joining on foreign keys
- excluding soft-deleted rows
- scoping by ownership and recency
Take this query:
Order::query()
->where('workspace_id', $workspaceId)
->where('status', 'paid')
->latest()
->paginate(50);
A lot of teams add separate indexes on workspace_id, status, and created_at, then wonder why the endpoint still drags.
Because real workloads often want a composite index aligned with the actual filter-plus-sort path, not a pile of unrelated single-column indexes.
A few blunt rules help:
- heavily used foreign keys should be indexed
- repeated filter combinations usually deserve composite indexes
- sort order matters when designing index structure
- soft-delete columns matter more than teams expect on hot tables
And do not guess. Use EXPLAIN.
If the execution plan is bad, no amount of elegant Eloquent will rescue it.
Example: when the next fix belongs in the schema, not the controller
Suppose a multi-tenant billing screen repeatedly filters invoices by workspace, status, and recency. The team trims columns and narrows eager loading, but performance still degrades as the table grows.
That often means the next real fix is one of these:
- a composite index like
(workspace_id, status, created_at) - moving archived rows out of the hot table
- replacing deep offset pagination on very large datasets
- introducing a summary table for dashboard metrics
That is why experienced teams stop treating Eloquent tuning as purely application-code work. Database design is part of the performance contract.
Batch jobs and reporting paths need different query discipline
Another place Laravel apps get hurt is background processing.
Queue jobs, exports, and sync workers are often written like oversized controllers. That works until the dataset becomes large enough to punish memory and runtime.
This is dangerous on a large table:
User::where('is_active', true)->get()->each(function ($user) {
// sync work
});
For larger workloads, use chunking or cursors.
User::query()
->where('is_active', true)
->chunkById(1000, function ($users) {
foreach ($users as $user) {
// sync work
}
});
Or:
foreach (User::where('is_active', true)->cursor() as $user) {
// process incrementally
}
Each pattern has tradeoffs:
| Pattern | Best for | Main risk |
|---|---|---|
get() |
small datasets | memory blowups |
paginate() |
user-facing lists | deep offset cost on large tables |
chunkById() |
jobs, exports, migrations | depends on stable ordered keys |
cursor() |
low-memory iteration | longer runtime, long-lived cursor |
And if the workload is effectively analytical, stop forcing it through hydrated models. Laravel’s query builder is often the better fit for reporting-style queries: https://laravel.com/docs/queries.
$rows = DB::table('orders')
->join('users', 'users.id', '=', 'orders.user_id')
->selectRaw('users.plan, COUNT(*) as order_count, SUM(orders.total) as revenue')
->where('orders.status', 'paid')
->groupBy('users.plan')
->get();
That is not anti-Eloquent. It is just honest about workload shape.
What to fix first in a real production app
If your Laravel app is already slow under load, do not start with random micro-optimizations. Start with the highest-leverage sequence.
- Measure query count and cumulative query time on hot routes.
- Enable lazy-loading protection outside production.
- Remove hidden relationship access from views, resources, accessors, and policies.
- Replace collection-based counts, sums, and existence checks with SQL-side operations.
- Narrow eager loading to exactly what each endpoint needs.
- Inspect deep
whereHas()chains and aggregate-heavy queries withEXPLAIN. - Add composite indexes for real filter-and-sort paths.
- Move reporting-style endpoints to query builder, raw SQL, or dedicated read models when appropriate.
That order works because it attacks the biggest sources of waste first.
The decision rule is simple: if a route is hot, treat its query shape as part of the endpoint contract. Decide exactly what the screen or API needs, keep relationships narrow, make SQL do aggregation work, and support the access pattern with the right indexes.
Eloquent is still one of Laravel’s biggest strengths. But under load, convenience without query discipline becomes a tax. The teams that scale well are the ones that stop admiring elegant model code and start respecting the database underneath it.
Read the full post on QCode: https://qcode.in/why-your-laravel-eloquent-queries-bottleneck-under-load-and-how-to-fix-them/











