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
Contents
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. Atotalaccessor 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
Sign in to join the conversation.
Sign in to comment