DEV Community

Cover image for Your Laravel app is probably slower because of query shape, not Eloquent itself
Saqueib Ansari
Saqueib Ansari

Posted on • Originally published at qcode.in

Your Laravel app is probably slower because of query shape, not Eloquent itself

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();
}
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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());
}
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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
  • EXPLAIN or EXPLAIN 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'));
Enter fullscreen mode Exit fullscreen mode

Then the Blade template does this:

{{ $invoice->customer->name }}
{{ $invoice->items->count() }}
{{ optional($invoice->payments->last())->status }}
{{ $invoice->due_date->isPast() ? 'Overdue' : 'On time' }}
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

And define a targeted relationship on the model:

public function latestPayment()
{
    return $this->hasOne(Payment::class)->latestOfMany();
}
Enter fullscreen mode Exit fullscreen mode

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();
}
Enter fullscreen mode Exit fullscreen mode

Better:

$projects = Project::withCount('tasks')->get();
Enter fullscreen mode Exit fullscreen mode

Bad:

if ($user->orders->count() > 0) {
    // ...
}
Enter fullscreen mode Exit fullscreen mode

Better:

if ($user->orders()->exists()) {
    // ...
}
Enter fullscreen mode Exit fullscreen mode

Bad:

$total = $user->payments->sum('amount');
Enter fullscreen mode Exit fullscreen mode

Better:

$total = $user->payments()->sum('amount');
Enter fullscreen mode Exit fullscreen mode

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_id or tenant_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);
Enter fullscreen mode Exit fullscreen mode

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
});
Enter fullscreen mode Exit fullscreen mode

For larger workloads, use chunking or cursors.

User::query()
    ->where('is_active', true)
    ->chunkById(1000, function ($users) {
        foreach ($users as $user) {
            // sync work
        }
    });
Enter fullscreen mode Exit fullscreen mode

Or:

foreach (User::where('is_active', true)->cursor() as $user) {
    // process incrementally
}
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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.

  1. Measure query count and cumulative query time on hot routes.
  2. Enable lazy-loading protection outside production.
  3. Remove hidden relationship access from views, resources, accessors, and policies.
  4. Replace collection-based counts, sums, and existence checks with SQL-side operations.
  5. Narrow eager loading to exactly what each endpoint needs.
  6. Inspect deep whereHas() chains and aggregate-heavy queries with EXPLAIN.
  7. Add composite indexes for real filter-and-sort paths.
  8. 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/

Top comments (0)