ddtcorex

27 Sept 2026 · 5 min read

The quote model: the most misunderstood object in Magento 2

How the quote, quote_item and quote_address tables shape every Magento checkout and why direct writes corrupt them

Part 1 of the Checkout & Payments series — the cart is not a cart, it is a quote.

The order that existed before the order

Every Magento order starts life as something else. A customer adds a product, enters an address, picks shipping, and Magento writes all of it into a quote. Only at the final place-order step does that quote convert into an order. If you treat the quote as a throwaway cart, you will corrupt totals, break payments, and create orders that cannot be invoiced. This post builds the correct mental model for the quote, because everything in this series, from totals to 3-D Secure, sits on top of it.

Frontend teams meet the quote through the Hyva checkout or Luma Knockout components, where the cart looks like simple UI state. Underneath, each keystroke that changes an address or shipping method triggers a totals recollection against the same quote object. For how that UI layer drives the quote, see the Hyva checkout deep dive.

Why the quote matters

The quote is the single source of truth for intent to buy. It holds who is buying, what they want, where it ships, how it ships, and what has been promised about price. The order, invoice, and shipment are downstream snapshots. A bug in the quote propagates into all of them, while a bug in the order usually stays contained.

Three properties make the quote tricky. First, it is long-lived: a guest quote can sit for days, an abandoned customer quote for weeks, accumulating stale addresses and expired coupons. Second, it is mutable until the last second: totals, taxes, and shipping are recollected on almost every checkout action. Third, it has two identities: the masked quote ID the storefront sees and the real entity_id the backend uses. Confuse the two in custom code and you update a quote the checkout never reads.

How it works

The quote is a small cluster of tables, not one row.

quote is the header. One row per cart, with entity_id, store_id, customer_id (nullable for guests), is_active, items_count, grand_total, and the reserved reserved_order_id that becomes the order increment ID at conversion. An active customer has at most one active quote per store; guests accumulate many, most abandoned.

quote_item is the lines. Each row links quote_id to a product via product_id and sku, with qty, price, base_price, row_total, tax_amount, and parent_item_id for configurables and bundles. The parent/child split matters: the configurable parent carries the price, the simple child carries the selection. Custom code that sums quote_item prices without respecting parent_item_id double counts every configurable.

quote_address is the context. Every quote has at least a billing address row and, for shippable carts, a shipping address row, distinguished by address_type. Shipping method, shipping amount, tax, and discounts collected per address land here before they roll up to the header. Virtual quotes have billing only, which is why shipping-method plugins must guard against a missing shipping address.

The lifecycle is strict: Magento\Quote\Model\QuoteRepository loads and saves, Magento\Quote\Api\CartManagementInterface::placeOrder() converts. Conversion copies quote, items, addresses, and payment into sales_order and friends, then deactivates the quote (is_active = 0). Never convert by hand with SQL; the sales tables expect the full copy plus the quote_id to order_id linkage in sales_model_service_quote_submit observers.

Useful ground truth checks:

SELECT entity_id, customer_id, is_active, items_count, grand_total, reserved_order_id
FROM quote WHERE customer_id = 123 AND is_active = 1;

SELECT item_id, sku, qty, price, row_total, parent_item_id
FROM quote_item WHERE quote_id = 456;

SELECT address_id, address_type, shipping_method, shipping_amount, tax_amount
FROM quote_address WHERE quote_id = 456;

And in code, always go through the repository, never the resource model directly:

$quote = $this->quoteRepository->get($cartId);
$quote->collectTotals();
$this->quoteRepository->save($quote);

Skipping collectTotals() before save is the most common reason custom checkout buttons show a stale grand total.

Pitfalls

  • Writing to quote tables with direct SQL. It bypasses validation, events, and totals recollection. The header total drifts from the item rows, and the next collectTotals() either overwrites your edit or crashes on inconsistent data. Use the repository.
  • Ignoring the masked ID. GraphQL and REST expose masked_id from quote_id_mask. Resolving it with the wrong store or ignoring guest versus customer ownership lets one shopper edit another cart. Always resolve through QuoteIdMaskFactory in store context.
  • Forgetting configurable parent/child pricing. Discount or fee modules that iterate all quote_item rows and add amounts per row charge configurables twice. Skip rows where parent_item_id is set, or operate on parents only.
  • Holding a quote object across requests. Quotes are reloaded per action for a reason. A stale in-memory quote saved after the customer changed address resurrects the old address. Load, mutate, collect, save, discard.
  • Leaving zombie active quotes. Failed payment redirects often leave an active quote plus a pending order. The customer returns, adds more items to the still-active quote, and places a second order. Handle the return route by reloading the exact quote by masked ID and checking is_active.

Field check on a live store

Pick one cart with a wrong total. Load the header, then sum quote_item.row_total for parent rows only and compare against quote.subtotal. If they disagree, a custom module wrote item rows without recollecting. Then inspect quote_address: two rows for shippable carts, correct address_type values, shipping method set on the shipping row only. Finally check reserved_order_id: if it is set on an active quote, a previous place-order attempt failed mid-conversion and a retry may double-submit without an idempotency guard. Fifteen minutes across these three tables localizes almost every checkout total mystery.

Closing

The quote is the checkout. Items describe intent, addresses describe context, and the header is only as honest as the last collectTotals(). Treat it as a managed object through the repository, respect the masked identity, and never let custom code outrun totals recollection. With this foundation, the next question is mechanical: how does that intent become a number.

Next in this series:

magento2-checkout-totals-collectors

magento2checkoutarchitecture