Laravel Magazine
Aggregate Queries Without N+1: withCount, withSum, withAvg, and withExists

Aggregate Queries Without N+1: withCount, withSum, withAvg, and withExists

Eric Van Johnson ·

A dashboard listing orders with their item count and total is one of the most common places an N+1 sneaks into a Laravel app, precisely because it looks harmless:

$orders = Order::latest()->take(20)->get();

// In the Blade view:
@foreach ($orders as $order)
    {{ $order->items->count() }} items, {{ $order->items->sum('price') }} total
@endforeach

That's 20 orders plus 40 additional queries — one to count items, one to sum prices, for every single order in the list. Eloquent has purpose-built helpers that fold every one of those into the original query.

withCount()

$orders = Order::withCount('items')->latest()->take(20)->get();

$orders[0]->items_count; // int, no extra query

withCount() adds a (SELECT COUNT(*) FROM order_items WHERE order_items.order_id = orders.id) as items_count subquery to the original SELECT. One query, no matter how many orders are in the result set. You can count with a constraint too:

Order::withCount(['items as pending_items_count' => function ($query) {
    $query->where('status', 'pending');
}])->get();

withSum(), withAvg(), withMin(), withMax()

The same pattern extends to every common aggregate:

Order::withSum('items', 'price')   // items_sum_price
    ->withAvg('items', 'price')     // items_avg_price
    ->withMin('items', 'price')     // items_min_price
    ->withMax('items', 'price')     // items_max_price
    ->get();

Each one generates its own correlated subquery column, and Eloquent casts the result to the correct numeric type automatically — a withSum() on a decimal column comes back as a string that behaves like Eloquent's other decimal attributes, not a lossy float.

withExists()

For a plain "does this have any at all" check, withExists() avoids even a COUNT, using an EXISTS subquery that the database can short-circuit on the first match:

Order::withExists('refunds')->get();

$order->refunds_exists; // bool

This is the correct replacement for $order->refunds->isNotEmpty() or $order->refunds()->exists() inside a loop — both trigger a query (or a full relationship load) per row, where withExists() folds the check into the original query as a boolean column.

Renaming the generated columns

The default {relation}_{aggregate}_{column} naming gets unwieldy fast. Use the as syntax to control it:

Order::withCount('items as item_count')
    ->withSum('items as items_total', 'price')
    ->get();

When to reach for a real join instead

These helpers are subqueries, not joins — great for "attach one aggregate value per row" but not a replacement for groupBy() reporting queries that need to aggregate across the whole table. If you're building a report ("total revenue per day across all orders"), a direct select(DB::raw(...))->groupBy() query is still the right tool. withCount() and its siblings are specifically for enriching a list of models with per-row aggregate data without falling into N+1.

Stay Updated

Subscribe to our newsletter

Get latest news, tutorials, community articles and podcast episodes delivered to your inbox.

Weekly articles
We send a new issue of the newsletter every week on Friday.
No spam
We'll never share your email address and you can opt out at any time.