Checkout & Conversion

Why custom logic in collectTotals() makes Magento checkout unstable

A Magento checkout can look correct in QA and still fail unpredictably when custom logic mutates quote state or creates hidden dependencies during totals collection.

Jason Schuman · April 25, 2026

I can spot an unstable checkout without opening the frontend

I can tell how unstable a Magento 2 checkout is without looking at the frontend. I just look for custom logic hiding in the totals collection pipeline. It is almost always there, and it is almost always the source of the bugs nobody can reproduce.

Why it keeps happening

Here is why it keeps happening: collectTotals() feels like the right place. Prices, discounts, shipping, tax, it is all right there. So teams add a condition, tweak a value, maybe call an external service.

QA signs off. The cart looks right. Everyone moves on.

The ghost bug

Then three weeks later, finance reports do not match orders. A coupon applies twice for one customer and not at all for another. Shipping totals flicker when switching methods.

And nobody can make it happen on demand. That is the ghost bug, and collectTotals() is where it lives.

Ghost bug in Magento?

A checkout can render correctly once and still be unstable. Rendering right one time proves nothing if the calculation behind it is not safe to repeat.

collectTotals() is a loop, not a moment

The core problem is that collectTotals() is not a single moment. It is a loop that runs multiple times per request, on quantity changes, address changes, shipping method selection, promo code entry, tax recalculation, and inventory revalidation. In 2.4.7+ with MSI and async indexing, it runs more often than most developers realize.

Magento assumes it is idempotent

Magento assumes totals collection is idempotent, meaning the same inputs produce the same output every time. The moment you inject logic that mutates state mid-pipeline, whether adjusting prices, writing to the quote, or calling an API, you break that assumption.

The first run looks right. The second run sees already-modified values. By the third run you are not calculating totals anymore, you are compounding side effects.

How the side effects show up

This is how promo codes "apply" in the cart and quietly disappear at order placement. This is how a FedEx rate call ends up in the critical checkout path, and checkout stability becomes dependent on a carrier API responding correctly, multiple times per request.

None of these announce themselves as a totals bug. They surface as finance mismatches, angry customers, and support tickets nobody can close.

Totals collection was never meant to hold business logic

Totals collection is a low-level aggregation step. It was never meant to carry business logic. Magento gives you proper extension points for pricing, discounts, and shipping decisions.

When those feel insufficient, the answer is not to get clever inside collectTotals(). It is to rethink where the decision actually belongs.

Where the logic actually belongs

Usually that answer is outside the checkout path entirely: async consumers, webhooks, post-order processing. Places where failure does not block revenue.

Some decisions genuinely have to happen before order placement, because they change what the customer pays or whether the order is valid. Everything else that does not gate revenue should move out of the pipeline that gates it.

How to find it in a review

This is findable without guessing. Look at di.xml for custom entries in the totals collector list, check sales.xml for registered total models, and grep the custom modules for plugins and observers attached to the quote and totals collection.

Then read what each one does during collection. The ones that write to the quote, mutate item prices, or make an outbound API call are the suspects, because those are the operations that are not safe to repeat.

If your checkout feels haunted

If your checkout feels haunted, do not start with Redis. Do not blame caching. Ask how much logic you have buried in a method that was never designed to hold it.