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();