N+1 queries in Product::getReviews()/getAcceptedReviews() in the shop catalog
Sylius version(s) affected
2.3.x-dev (branch 2.3, commit 70cf6ca6, 2026-08-17). Not a crash or regression tied to a specific release — the affected code (Product::getReviews()/getAcceptedReviews()) has been structurally the same for a long time, so this likely also affects released 2.2.x.
Description
Not a user-facing bug — a performance finding: Product::getReviews()/getAcceptedReviews() reads a lazy-loaded reviews collection, causing one extra Doctrine query per product (N+1) when a product listing or detail page surfaces review/rating data for multiple products.
Found by running query-guard (a PHPUnit extension that traces Doctrine queries during a normal test run) over the existing tests/Api/Shop functional/API test suite — no new tests, no synthetic scenarios, just the SQL those tests already produce. query-guard fingerprints queries by call site and SQL shape, flags a call site as n-plus-one when it fires the same-shaped query 3+ times with differing bound values inside one test (batched IN (...) lookups are excluded), and separately inspects the call stack at query time: when it finds an uninitialized Doctrine PersistentCollection for a named entity property, the finding is upgraded from a heuristic guess to a confirmed fact — that's the case here.
src/Sylius/Component/Core/Model/Product.php:176:
public function getReviews(): Collection
{
return $this->reviews; // ← uninitialized PersistentCollection
}
public function getAcceptedReviews(): Collection
{
return $this->reviews->filter(...); // same collection, same lazy-load
}Observed in two different product-catalog scenarios: ProductsTest::it_returns_products_collection_with_only_available_associations (4 queries) and ProductsTest::it_returns_products_with_reviews (3 queries). Both go through the Shop product API, where reviews/rating data is part of the product representation — a product listing or detail page that surfaces review counts or average ratings pays one extra query per product touched, instead of the reviews for the page's products being loaded together.
vendor/bin/phpstan analyse and vendor/bin/ecs check (the project's own configured tools) both pass clean on Product.php, so this isn't tangled up with an existing lint/type issue.
How to reproduce
Step 1: Check out Sylius/Sylius at 2.3 (or run the existing test suite on any recent 2.3 commit).
Step 2: Run tests/Api/Shop with a Doctrine query tracer/profiler attached (e.g. query-guard, or manually log queries via a DBAL middleware), or reproduce manually: list or view shop products that have reviews, where the response includes review/rating data for multiple products, with the Symfony profiler's Doctrine panel open.
Step 3: Observe one extra query per product touched, instead of reviews being eager-loaded or batch-fetched once for all products on the page.
Possible Solution
Eager-load reviews (or precompute/cache the accepted-review count and average rating instead of deriving them from the live collection on every read) for the product(s) being serialized, instead of relying on Product::$reviews's lazy default.
Additional Context
Not applicable as a log — this was found via query trace analysis (query-guard), not an error log. Happy to share the full trace/counts if useful. Related: two other N+1 findings from the same query-guard run are filed separately (order totals in the admin order list, cart item unit adjustments) since they're independent code paths.
Source: Sylius/Sylius