N+1 queries in Order::getTaxTotal()/getShippingTaxTotal() in the admin order list
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 (Order/OrderItem getters) 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: computing Order::getTaxTotal()/getShippingTaxTotal() does one extra Doctrine query per order (and per item inside it) in a loop (N+1), because it reads a lazy-loaded adjustments collection without eager-loading it first.
Found by running query-guard (a PHPUnit extension that traces Doctrine queries during a normal test run) over the existing tests/Api/Admin 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/Order/Model/Order.php:208 (getAdjustments(), the lazy-load site) and src/Sylius/Component/Core/Model/Order.php:391-414 (getTaxTotal()/getShippingTaxTotal(), the callers):
// src/Sylius/Component/Order/Model/Order.php:208
public function getAdjustments(?string $type = null): Collection
{
if (null === $type) {
return $this->adjustments; // ← uninitialized PersistentCollection
}
return $this->adjustments->filter(...);
}
// src/Sylius/Component/Core/Model/Order.php:391
public function getTaxTotal(): int
{
$taxTotal = 0;
foreach ($this->getAdjustments(AdjustmentInterface::TAX_ADJUSTMENT) as $taxAdjustment) {
$taxTotal += $taxAdjustment->getAmount();
}
foreach ($this->items as $item) {
$taxTotal += $item->getTaxTotal(); // ← same lazy-load cascade one level down, on OrderItem
}
return $taxTotal;
}taxTotal and shippingTaxTotal are both serialized attributes on the Order API resource, included in the sylius:admin:order:index serialization group (src/Sylius/Bundle/ApiBundle/Resources/config/serialization/Order.xml:163-174) — i.e. the admin order list endpoint (GET /api/v2/admin/orders), not just the single-order detail view. For every order on a page of the admin order list, computing these two fields lazily loads that order's own $adjustments collection, then does it again per OrderItem inside the order (OrderItem::getTaxTotal() calls its own getAdjustments(), same lazy-load shape one level down — src/Sylius/Component/Order/Model/OrderItem.php:212,255,279).
Observed in the trace: 8 queries from the top-level Order::$adjustments lazy-load alone, in one test with a handful of orders (OrdersTest::it_gets_orders_filtered_by_different_currencies) — a production admin order list with dozens of orders per page, each with several line items, pays this cost per order and per item on every page load.
vendor/bin/phpstan analyse and vendor/bin/ecs check (the project's own configured tools) both pass clean on Order.php (both the Order/Model and Core/Model copies) and OrderItem.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/Admin with a Doctrine query tracer/profiler attached (e.g. query-guard, or manually log queries via a DBAL middleware), or reproduce manually: load a page of GET /api/v2/admin/orders with several orders, each having multiple items and adjustments, with the Symfony profiler's Doctrine panel open.
Step 3: Observe one extra query per order and per item inside it, instead of the adjustments association being eager-loaded or batch-fetched once for the whole page.
Possible Solution
Eager-load adjustments (via JOIN/fetch=EAGER, or a dedicated batch query keyed by the page's order IDs before serialization) on the query that backs sylius:admin:order:index, instead of letting the serializer call getTaxTotal()/getShippingTaxTotal() per order and per item, each triggering its own lazy load.
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 (cart item unit adjustments, product reviews) since they're independent code paths.
Source: Sylius/Sylius