Skip to content
article

Your Laravel app is not slow. Your loop is.

Eloquent N+1 in a real client project: 1,214 queries on one page, and how we got it to 9

Ayesha Siddiqui@laravel_ayesha

June 7, 2026 · 3 min read

A Dubai client called last month. "The admin is slow, maybe we need a bigger server?" The orders page took 6.1 seconds. I opened Debugbar and the query counter said 1,214. For one page. Of 50 orders.

The server was fine. The loop was not.

The code (simplified, but not by much)

// OrderController@index
$orders = Order::latest()->paginate(50);
{{-- orders/index.blade.php --}}
@foreach ($orders as $order)
    <tr>
        <td>{{ $order->customer->name }}</td>
        <td>{{ $order->shippingAddress->city }}</td>
        <td>{{ $order->items->count() }} items</td>
        <td>{{ $order->items->sum('price') }}</td>
        <td>
            @foreach ($order->items as $item)
                {{ $item->product->name }},
            @endforeach
        </td>
    </tr>
@endforeach

Every ->customer, ->shippingAddress and ->items is a lazy load: one query per order. And inside the inner loop, ->product is one query per item. 50 orders with about 21 items each, you do the maths. 1 query to page the orders, 1 to count them, 50 + 50 + 50 for the relations, and around 1,060 for products.

The fix

$orders = Order::query()
    ->with([
        'customer:id,name',
        'shippingAddress:id,order_id,city',
        'items.product:id,name',
    ])
    ->withCount('items')
    ->withSum('items', 'price')
    ->latest()
    ->paginate(50);

And in Blade use $order->items_count and $order->items_sum_price, which come from subqueries in the main query. Note that when you select columns in an eager load you must include the keys (id and the foreign key), otherwise Eloquent cannot match the children to the parents and you get empty relations and a very confused junior.

Result: 9 queries (3 of them are from auth and a settings middleware), 6.1s down to 180ms. The client said "see, the new server is faster". We did not change the server. Acha, fine.

Make it impossible to do again

In AppServiceProvider::boot():

use Illuminate\Database\Eloquent\Model;

Model::preventLazyLoading(! app()->isProduction());

Now any lazy load in local or staging throws a LazyLoadingViolationException. Your juniors will hate you for one week and then never write this bug again. If you want the full strict mode (also catches typos in attribute names and silently discarded attributes) use Model::shouldBeStrict() instead.

In production you do not want exceptions, but you do want to know:

Model::handleLazyLoadingViolationUsing(function ($model, $relation) {
    logger()->warning('N+1: '.get_class($model)."::{$relation}");
});

The sneaky ones

  • $order->items()->count() vs $order->items->count(). With brackets it is a new query every time, even if the items are already loaded. Without brackets it counts the loaded collection.
  • Accessors in $appends. A total accessor that does $this->items->sum(...) looks innocent until an API returns 100 orders and serialises every one. The controller has no loop, the N+1 is hiding in the model.
  • API Resources. Use $this->whenLoaded('items') so the resource never triggers a load on its own.
  • Exports. For big exports use Order::with('items')->lazy(500). It chunks under the hood and eager loads per chunk. cursor() uses less memory but cannot eager load, so it brings the N+1 right back.

Debugbar or Telescope in local, preventLazyLoading on, and look twice at every PR that touches a @foreach in Blade. That's it. That is the "bigger server".

Written by

Ayesha Siddiqui

Laravel dev turned agency owner, Karachi. 11 people, mostly PHP, all of them better than me now. PHP 8 is good and I will die on this hill. Founder of PHP Still Pays 🐘

Get the best of #php_still_pays weekly

One email each Monday with the most useful posts. No spam; unsubscribe in one click.

Comments

11

Sign in to join the conversation.

Sign in to comment
Oluwatobi Adeyemi@tobi_freelance#6 in PHP Still Pays
That shouldBeStrict line is going into every project starting today. Omo, 1,214 queries. The DB was crying for help
3 net votes
Bayu Setiawan@bayu_laravel#2 in PHP Still Pays
Good one mbak. The $appends trap is the one that got me. API return 100 rows, every row lazy load the relation inside the accessor. 400 queries for one JSON in our koperasi app.
1 net votes
Ayesha Siddiqui@laravel_ayesha#1 in PHP Still Pays
yes!! that one is sneaky because the controller looks completely innocent. whenLoaded in the Resource fixed it for us
0 net votes
Frank Ostrowski@frank_cpp98#10 in PHP Still Pays
A framework that lets you issue 1,214 queries from a template without complaint is not a framework. It is an accomplice. Regards.
2 net votes
Ayesha Siddiqui@laravel_ayesha#1 in PHP Still Pays
Frank, Hibernate would like a word. so would every ORM since 2003. but thank you for reading a PHP post, I know it hurt 😐
2 net votes
Rodel Bautista@rodel_java4 rep in PHP Still Pays
She is right. Hibernate lazy loading inside a JSP loop, 2009, same bug. Different logo.
1 net votes
Frank Ostrowski@frank_cpp98#10 in PHP Still Pays
I do not use Hibernate. I use SQL. Regards.
3 net votes
Caio Nascimento@caio_backend#9 in PHP Still Pays
Spring Data JPA user here confirming: same bug, more XML kkkkk. The EntityGraph annotation is our with()
4 net votes
Kuya Migs@kuya_migs_codes1 rep in PHP Still Pays
Grabe yung 1,214 lods 😂 sharing this sa mga students ko na nag-La-Laravel, lagi nila tinatanong bakit mabagal yung admin dashboard nila 🔥
1 net votes
Faizan Qureshi@faizan_php#4 in PHP Still Pays
Ayesha baji, aaj preventLazyLoading on kiya, pehle hi page pe 14 exceptions aaye 😅 ab ek ek karke theek kar raha hoon. behad shukriya
5 net votes
Ayesha Siddiqui@laravel_ayesha#1 in PHP Still Pays
14 on the first page is a normal welcome. fix them one by one, don't just switch it off 😄
6 net votes