Reader Stacks

GROUP BY and HAVING in Laravel Eloquent

GROUP BY collapses rows into per-group summaries; HAVING filters those summaries afterward — the key distinction from WHERE, which filters individual rows before any grouping happens.

groupBy() and having() answer a different kind of question than a normal filtered query — not "which individual rows match," but "summarize the rows into groups, and show me only the groups that meet some criteria."

1. Basic groupBy() with an aggregate

use Illuminate\Support\Facades\DB;

$ordersByStatus = Order::select('status', DB::raw('COUNT(*) as total'))
    ->groupBy('status')
    ->get();

foreach ($ordersByStatus as $row) {
    echo "{$row->status}: {$row->total}";
}

This collapses every row into one row per distinct status value, with total holding the count of original rows in each group. Every non-aggregated column in the select() needs to also appear in groupBy() — MySQL in strict mode (the Laravel default) enforces this and errors otherwise.

2. Grouping by multiple columns

$revenueByStatusAndMonth = Order::select(
        'status',
        DB::raw('MONTH(created_at) as month'),
        DB::raw('SUM(total) as revenue')
    )
    ->groupBy('status', DB::raw('MONTH(created_at)'))
    ->get();

3. having() — filtering the grouped results

$busyCategories = Product::select('category_id', DB::raw('COUNT(*) as product_count'))
    ->groupBy('category_id')
    ->having('product_count', '>', 10)
    ->get();

having() filters on the aggregated result after grouping — this is the essential distinction from where(), which can only filter based on values that exist on the original, ungrouped rows.

4. Why having() exists instead of just using where()

// This does NOT work — product_count doesn't exist until after grouping happens
Product::select('category_id', DB::raw('COUNT(*) as product_count'))
    ->where('product_count', '>', 10) // error: unknown column
    ->groupBy('category_id')
    ->get();

WHERE is evaluated before grouping occurs, so it has no access to an aggregated value like product_count that's only computed as part of the grouping step — HAVING exists specifically because SQL needs a distinct clause that runs after aggregation, to filter on the aggregated values themselves.

5. Combining where(), groupBy(), and having() together

$topSellingCategories = Order::join('order_items', 'orders.id', '=', 'order_items.order_id')
    ->where('orders.status', 'completed')        // filters individual rows, before grouping
    ->select('order_items.category_id', DB::raw('SUM(order_items.quantity) as total_sold'))
    ->groupBy('order_items.category_id')          // groups the filtered rows
    ->having('total_sold', '>', 100)               // filters the resulting groups
    ->get();

The order these are conceptually applied in SQL — filter rows (WHERE), then group them (GROUP BY), then filter the groups (HAVING) — is exactly why each clause exists as a separate step rather than one being able to substitute for another.

6. havingBetween, for a range on the aggregated value

Product::select('category_id', DB::raw('AVG(price) as avg_price'))
    ->groupBy('category_id')
    ->havingBetween('avg_price', [20, 100])
    ->get();
Topics: Database Queries & Eloquent