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:
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 allwithSum() 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.