28 Sept 2026 · 4 min read
Totals collectors: how a cart arrives at a number
How the totals collector pool XML turns quote items, shipping and tax into one grand total without double counting
Part 2 of the Checkout & Payments series — the grand total is collected, never calculated.
The number everyone argues about
Subtotal, discount, shipping, tax, grand total. Five numbers, five teams convinced theirs is right, and one customer who only trusts the last one. In Magento the grand total is never a single formula. It is the output of a pipeline of collectors, each adding or subtracting its slice in a defined order. Understand the pipeline and totals bugs become ordering bugs. Ignore it and every fix becomes a new rounding error.
Why collectors exist
A cart total has competing claims. Catalog prices want to apply first, cart rules second, shipping third, tax last, with store credit and gift cards somewhere in between depending on locale. Hardcoding that sequence in one method would make every merchant customization a core override. The collector pool turns the sequence into configuration: each total is a small class, declared in XML, sorted by sort_order, run in turn against the same quote and address objects.
This design also explains why totals change when nothing visible changed. Selecting a shipping method, entering a postcode, or applying a coupon triggers collectTotals(), which reruns the whole pool. A collector that reads stale address data or caches a previous result poisons every collector after it. The pipeline is only as deterministic as its inputs.
How it works
Each collector implements Magento\Quote\Model\Quote\Address\Total\AbstractTotal with two methods: collect() mutates the address and quote, fetch() exposes the display slice. The pool that runs them is declared in sales.xml and totals.xml:
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
<section name="quote">
<group name="totals">
<item name="subtotal" instance="Magento\Quote\Model\Quote\Address\Total\Subtotal" sort_order="10"/>
<item name="discount" instance="Magento\SalesRule\Model\Quote\Discount" sort_order="20"/>
<item name="shipping" instance="Magento\Quote\Model\Quote\Address\Total\Shipping" sort_order="40"/>
<item name="grand_total" instance="Magento\Quote\Model\Quote\Address\Total\Grand" sort_order="80"/>
</group>
</section>
</config>
Tax collectors from Magento\Tax insert themselves into this order depending on the tax calculation sequence (unit versus row, including versus excluding tax). The exact sort_order values vary by module, but the principle holds: subtotal first, adjustments in the middle, grand total last as a summation.
At runtime, Magento\Quote\Model\Quote\Address\TotalCollector::collect() loops the sorted collectors for each address, then Magento\Quote\Model\Quote\TotalsCollector merges address totals into the quote header. The shipping address total drives the grand total for shippable quotes; billing drives virtual ones. fetch() then feeds the totals_data segments the storefront and the totals REST endpoint return.
Debugging starts with the segments, not the header:
bin/magento dev:query-log:enable
# place a test cart change, then inspect which collector queries ran
$totals = $this->totalsCollector->collect($quote);
foreach ($quote->getTotals() as $code => $total) {
$this->logger->info($code . ': ' . $total->getValue());
}
When a custom fee appears twice, it is almost always registered for both addresses or its collect() adds to a value another collector already summed.
Pitfalls
- Adding amounts in the wrong collector phase. A fee added in
subtotalgets discounted and taxed; the same fee added after tax does not. Pick the phase deliberately and document which collectors run before and after yours. - Forgetting the address split. Collectors run per address. Code that adds a cart-wide fee inside
collect()without checkingaddress_typeapplies it twice on shippable carts. Guard with$address->getAddressType() === Address::TYPE_SHIPPINGor restrict to virtual handling. - Rounding at the row instead of the total. Rounding each item row then summing produces penny drift on large carts. Follow core behavior: keep full precision through collection, round only in
fetch()and the grand total summation. - Overriding sort_order blindly. Moving a discount collector after tax changes every tax-inclusive price on the store. If two third-party modules both claim the same
sort_order, the merge order decides silently. Declare an explicit order and verify withbin/magento config:showplus a cart probe, not by reading XML alone. - Caching totals across address changes. Saving a computed total in a singleton or registry and reusing it after the postcode changed serves yesterday's tax. Collectors must be stateless across calls; any cache key must include quote ID, address data, and coupon code.
Field check on a live store
Take a cart where the grand total is off by a small amount. Dump $quote->getTotals() keyed by code and compare each segment against its expected contribution. The first segment that diverges identifies the faulty collector; everything after it is collateral. Then check the collector XML merge for duplicate codes and overlapping sort_order values. On one store, a one-cent drift on every tenth order traced to a custom fee rounding per row while core rounded the total. Moving the rounding to fetch() removed the drift without changing a single price.
Closing
Totals are a pipeline, not a formula. Subtotal feeds discounts, discounts feed shipping and tax, and grand total only sums what the pool produced. Debug by segment, fix by ordering, and keep rounding at the end. Once the number is honest, it needs to survive the payment call, which is where the gateway command pool takes over.
Next in this series:
magento2-payments-gateway-command-pool