Counting Relations

Counting and Aggregating Related Models

To print "27 reviews, 3.2 stars" you need numbers, not models. withCount() adds {relation}_count, and withSum(), withAvg(), withMin(), withMax() and withExists() add {relation}_{function}_{column}. Each becomes a correlated subquery in the select list, so the parent stays one statement. Aliases and closures work as in with(), and loadCount() or loadSum() add aggregates to models you already hold:

Aggregates as subqueries, and their cost against loading the childrenSQL
use App\Models\Product;
Product::select('id')
    ->withCount(['reviews', 'reviews as five_stars' => fn ($q) => $q->where('rating', 5)])
    ->withAvg('reviews as avg_rating', 'rating')
    ->withSum('orders as sold', 'order_items.quantity')
    ->whereKey([1, 4])->get()->each(fn ($p) => print($p->toJson().PHP_EOL));
measure('with()', fn () => Product::with('reviews')->get()
    ->map(fn ($p) => [$p->reviews->count(), $p->reviews->avg('rating')]));
measure('withCount', fn () => Product::withCount('reviews')->withAvg('reviews', 'rating')
    ->get()->map(fn ($p) => [$p->reviews_count, $p->reviews_avg_rating]));
Output
{"id":1,"reviews_count":27,"five_stars":6,"avg_rating":"3.2222","sold":"65"}
{"id":4,"reviews_count":26,"five_stars":6,"avg_rating":"3.1538","sold":"42"}
with()          2 queries    10.2 ms in MySQL    90.6 ms in all
withCount       1 queries     9.6 ms in MySQL    12.8 ms in all

withSum() reached through the pivot to order_items.quantity, and MySQL 524 returns SUM and AVG of integers as DECIMAL, hence the strings. Loading all 5,000 reviews to count them in PHP took seven times as long. Call select() before the aggregate methods, or it replaces their columns.