Quote Management API in Magento 2: Controlling Cart Logic Programmatically
AI generated
M2
di.xml
Magento 2 · Quote API · GraphQL · PHP 8.4
Quote Management API in Magento 2
Controlling cart logic programmatically instead of hacking models

Anyone building headless checkouts, POS integrations or marketplace sync for Magento 2 cannot avoid the Quote Management API. CartRepositoryInterface, CartManagementInterface and custom totals collectors replace fragile direct model access with stable, versioned service contracts that stay reliable even under heavy cart load.

18 min read CartRepositoryInterface · GraphQL Cart Mutations · Custom Totals Magento 2.4.8-p4 · PHP 8.4

1. What the Quote Management API does and when you need it programmatically

A Magento cart is internally a Quote entity: a preliminary stage of the later order, consisting of items, addresses, shipping and payment data, and calculated totals. The Quote Management API is the set of service contracts through which this entity is controlled, created, read, changed and saved: CartRepositoryInterface, CartManagementInterface, GuestCartManagementInterface and the associated data interfaces. It is therefore not merely an implementation detail, but the foundation of every integration that needs to control a cart outside the classic storefront session flow.

Programmatic access is needed in several concrete scenarios: in headless checkouts, where a custom frontend talks directly to the Quote Management API via GraphQL or REST without ever owning a classic Magento session. In POS integrations, which must represent sales from physical register systems as carts in Magento, sometimes minutes after the actual sale. In marketplace sync, when orders from external channels are prepared as a quote before being converted into an order. And in custom B2B ordering flows such as quick order or requisition lists, which deliberately bypass the standard cart.

In all of these cases, direct access to \Magento\Quote\Model\Quote is technically possible but risky: model classes are not part of the stable API surface and can change between minor releases. The Quote Management API, on the other hand, is versioned and protected as a service contract, which makes it the only reliable foundation for production integrations.

2. CartRepositoryInterface and CartInterface in detail: service contracts instead of direct model access

CartRepositoryInterface is the central entry point of the Quote Management API for persistence: get() loads a quote by ID, getActive() loads only active carts, getForCustomer() loads the quote of a logged-in customer, save() persists changes, and delete() removes a quote entirely. Internally, the repository delegates to \Magento\Quote\Model\QuoteRepository, which in turn works against the declaratively defined tables quote, quote_item and quote_address. Anyone writing directly against the model instead loses this abstraction layer and risks inconsistent state.

The associated data interface CartInterface describes the quote as a pure data object: getId(), getItems(), getBillingAddress(), getShippingAddress(), getPayment(), setCustomerId() and further getters and setters that work independently of the concrete ORM implementation. This contract makes it possible to write plugins and extensions that build on the interface instead of the model class, which significantly simplifies testing: in unit tests, CartRepositoryInterface can easily be mocked without simulating a real database connection.

An important detail when working with the Quote Management API: CartRepositoryInterface::get() throws a NoSuchEntityException if the quote ID does not exist or is no longer active. Anyone who does not explicitly handle this exception produces hard-to-trace, 404-like errors in integrations. Consistently using service contracts instead of direct model access is therefore not a stylistic ideal, but a hard requirement for resilient integrations.

3. Building a cart programmatically: adding items, setting addresses, choosing a shipping method

The typical flow of building a cart programmatically via the Quote Management API follows a fixed pattern: first, an empty quote is created via CartManagementInterface::createEmptyCart(), which returns its ID. The quote is then loaded via CartRepositoryInterface::get() so that addProduct() can be called for each desired item. This method exists directly on the quote object and automatically handles price calculation, stock checking and stock item assignment.

For shipping and payment, addresses must then be set: getShippingAddress() returns the quote's address object, which is populated with addData(). With setCollectShippingRates(true) and collectShippingRates(), available shipping methods are determined, from which the appropriate one is selected via setShippingMethod(). Only the final call to collectTotals() and save() via the repository persists the cart including all totals.

The following example shows a complete service class using constructor property promotion that encapsulates exactly this flow while relying exclusively on service contracts of the Quote Management API:


<?php

declare(strict_types=1);

namespace Mironsoft\QuoteApi\Service;

use Magento\Quote\Api\CartManagementInterface;
use Magento\Quote\Api\CartRepositoryInterface;
use Magento\Quote\Api\Data\CartInterface;
use Magento\Catalog\Api\ProductRepositoryInterface;
use Magento\Store\Model\StoreManagerInterface;
use Magento\Quote\Model\Quote\Address\Rate;

/**
 * Builds a Magento quote programmatically without touching storefront session state.
 */
class ProgrammaticQuoteBuilder
{
    public function __construct(
        private readonly CartManagementInterface $cartManagement,
        private readonly CartRepositoryInterface $cartRepository,
        private readonly ProductRepositoryInterface $productRepository,
        private readonly StoreManagerInterface $storeManager,
    ) {
    }

    /**
     * Creates a new quote, adds items, sets shipping address and selects a shipping method.
     *
     * @param array<string, int> $skuToQty
     * @param array<string, string> $shippingAddressData
     * @return CartInterface
     * @throws \Magento\Framework\Exception\NoSuchEntityException
     * @throws \Magento\Framework\Exception\LocalizedException
     */
    public function build(array $skuToQty, array $shippingAddressData): CartInterface
    {
        $storeId = (int) $this->storeManager->getStore()->getId();
        $quoteId = $this->cartManagement->createEmptyCart();
        $quote = $this->cartRepository->get($quoteId);
        $quote->setStoreId($storeId);

        foreach ($skuToQty as $sku => $qty) {
            $product = $this->productRepository->get((string) $sku, false, $storeId);
            $quote->addProduct($product, (int) $qty);
        }

        $shippingAddress = $quote->getShippingAddress();
        $shippingAddress->addData($shippingAddressData);
        $shippingAddress->setCollectShippingRates(true);
        $shippingAddress->collectShippingRates();

        $rates = $shippingAddress->getGroupedAllShippingRates();
        foreach ($rates as $carrierRates) {
            foreach ($carrierRates as $rate) {
                /** @var Rate $rate */
                $shippingAddress->setShippingMethod($rate->getCode());
                break 2;
            }
        }

        $quote->setTotalsCollectedFlag(false);
        $quote->collectTotals();

        return $this->cartRepository->save($quote);
    }
}

4. Custom totals collector: hooking your own pricing logic into the quote via TotalSegmentInterface

Totals calculation in the Quote Management API runs through a pipeline of collector classes registered via di.xml under Magento\Quote\Model\Quote\TotalsCollector with a sortOrder. Each collector extends \Magento\Quote\Model\Quote\Address\Total\AbstractTotal and implements two methods: collect() calculates the value and writes it into the address's Total object, fetch() returns it as a segment for the API response, conceptually compatible with \Magento\Quote\Api\Data\TotalSegmentInterface.

A typical use case: a handling fee calculated based on the total weight of the order, which needs to be visible in both REST and GraphQL responses as its own totals entry, outside the standard tax and discount logic. Without a custom collector, this logic would have to be hidden either in a product price or manipulated afterwards on the order, both error-prone and hard to trace in reports.

The following class implements exactly this case as a standalone totals collector within the Quote Management API pipeline:


<?php

declare(strict_types=1);

namespace Mironsoft\QuoteApi\Model\Total;

use Magento\Quote\Model\Quote;
use Magento\Quote\Model\Quote\Address\Total;
use Magento\Quote\Model\Quote\Address\Total\AbstractTotal;
use Magento\Quote\Api\Data\ShippingAssignmentInterface;

/**
 * Adds a custom "handling fee" total segment to the quote based on item weight.
 */
class HandlingFeeTotal extends AbstractTotal
{
    private const CODE = 'handling_fee';
    private const WEIGHT_THRESHOLD_KG = 20.0;
    private const FEE_AMOUNT = 4.90;

    /**
     * Sets the total segment code used in totals responses.
     */
    public function __construct()
    {
        $this->setCode(self::CODE);
    }

    /**
     * Collects the handling fee and writes it into the address total.
     *
     * @param Quote $quote
     * @param ShippingAssignmentInterface $shippingAssignment
     * @param Total $total
     * @return $this
     */
    public function collect(
        Quote $quote,
        ShippingAssignmentInterface $shippingAssignment,
        Total $total
    ): self {
        parent::collect($quote, $shippingAssignment, $total);

        $items = $shippingAssignment->getItems();
        if (!$items) {
            return $this;
        }

        $totalWeight = 0.0;
        foreach ($items as $item) {
            $totalWeight += (float) $item->getWeight() * (float) $item->getQty();
        }

        $fee = $totalWeight > self::WEIGHT_THRESHOLD_KG ? self::FEE_AMOUNT : 0.0;

        $total->setTotalAmount(self::CODE, $fee);
        $total->setBaseTotalAmount(self::CODE, $fee);
        $total->setHandlingFee($fee);

        return $this;
    }

    /**
     * Exposes the segment as a total segment array for API responses.
     *
     * @param Quote $quote
     * @param Total $total
     * @return array<string, mixed>|null
     */
    public function fetch(Quote $quote, Total $total): ?array
    {
        $fee = (float) $total->getHandlingFee();
        if ($fee <= 0.0) {
            return null;
        }

        return [
            'code' => self::CODE,
            'title' => __('Handling Fee'),
            'value' => $fee,
        ];
    }
}

5. GraphQL cart mutations (addSimpleProductsToCart, setShippingMethodsOnCart) vs. REST quote endpoints compared

The Quote Management API is reachable through two parallel API layers. GraphQL cart mutations such as addSimpleProductsToCart, setShippingMethodsOnCart or setBillingAddressOnCart work with a masked cart_id, which is mapped to the internal quote ID in the quote_id_mask table. The client only holds this token, never the real ID, which enforces a clean separation between public and internal identifier and makes enumeration attacks harder.

REST endpoints such as /V1/carts/mine for logged-in customers or /V1/guest-carts/:cartId for guests expose the same Quote Management API, but differ in their auth model: REST requires either a customer token or a public guest cart ID, while GraphQL additionally allows multiple operations to be bundled into a single request, which noticeably saves latency when several cart changes happen per page load. Direct service contract access from your own PHP code bypasses both HTTP layers entirely and is therefore the fastest option, but also the most tightly coupled to the Magento process.

The following table compares all three access paths of the Quote Management API before the concrete code examples that follow:

Access path Typical use case Latency Statelessness Auth model
REST Quote API Simple integrations, POS connectivity Medium, one request per operation Fully stateless Customer token / guest cart ID
GraphQL Cart Mutations Headless storefronts, PWA checkouts Low, bundled operations Fully stateless Customer token / masked cart ID
Direct Service Contract Consumers, admin tools, internal jobs Minimal, no HTTP overhead Bound to process In-process, no token

A GraphQL cart mutation example shows how two operations of the Quote Management API can be bundled once a cart ID is available:


mutation AddSimpleProductAndSetShipping($cartId: String!, $sku: String!, $qty: Float!) {
  addSimpleProductsToCart(
    input: {
      cart_id: $cartId
      cart_items: [{ data: { sku: $sku, quantity: $qty } }]
    }
  ) {
    cart {
      id
      items {
        quantity
        product { sku }
      }
    }
  }
}

mutation SetShipping($cartId: String!, $carrierCode: String!, $methodCode: String!) {
  setShippingMethodsOnCart(
    input: {
      cart_id: $cartId
      shipping_methods: [{ carrier_code: $carrierCode, method_code: $methodCode }]
    }
  ) {
    cart {
      shipping_addresses {
        selected_shipping_method {
          carrier_code
          method_code
        }
      }
    }
  }
}

The equivalent REST access to the Quote Management API shows the same flow over two separate requests:


# Create a guest cart via the REST Quote API
curl -s -X POST "https://shop.example.com/rest/V1/guest-carts" \
  -H "Content-Type: application/json"
# Response: "b8f3b3b1b1b1b1b1b1b1b1b1b1b1b1b1"

# Add an item to the guest cart
curl -s -X POST "https://shop.example.com/rest/V1/guest-carts/b8f3b3b1.../items" \
  -H "Content-Type: application/json" \
  -d '{
    "cartItem": {
      "sku": "24-MB01",
      "qty": 2,
      "quote_id": "b8f3b3b1b1b1b1b1b1b1b1b1b1b1b1b1"
    }
  }'

# Response contains item_id, price, qty and quote_id

6. Understanding guest-cart-to-customer-cart merge logic and controlling it via a plugin

When a guest logs in during an active cart session, CartManagementInterface::assignCustomer() kicks in within the Quote Management API. By default, Magento tries to merge the guest quote into an already existing active customer quote, depending on the settings under Sales > Checkout > Shopping Cart. This behavior makes sense for classic storefronts, but can lead to unwanted effects in special integration scenarios.

One example: for marketplace accounts that are active across multiple channels at the same time, a guest quote from a POS sale should not automatically be merged with the same customer's online quote. Here, a plugin around assignCustomer() is registered that either suppresses the merge or delegates the decision to a custom rule, for example based on a custom attribute on the quote marking its channel of origin.

Two pitfalls need to be considered when customizing this part of the Quote Management API: first, without clean merge handling, orphaned quotes with is_active = 0 can pile up, wasting storage and skewing reports. Second, calling assignCustomer() concurrently from two parallel requests, for example during login across multiple tabs, can potentially cause a race condition in which both requests independently execute the same merge operation.

7. Quote validation: hooking custom validators into QuoteValidator via a plugin

\Magento\Quote\Model\QuoteValidator::validateQuote() is the central check point of the Quote Management API before checkout completion. By default, it checks whether the quote contains any items at all, whether it is active, and whether all items are still purchasable. This check runs both in the classic one-page checkout and in programmatic order placement via CartManagementInterface::placeOrder().

For additional business logic, a plugin is registered on QuoteValidator, for example to check whether all items are actually in stock in a specific POS warehouse, or whether a quote created via marketplace sync is not older than a defined time span before it is converted into an order. An around plugin can extend the default check without replacing it, while an after plugin is usually sufficient for pure additional checks.

Registering such a plugin happens via di.xml, as shown in the following example for a freshness check on marketplace quotes:


<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xsi:noNamespaceSchemaLocation="urn:magento:framework:ObjectManager/etc/config.xsd">
    <type name="Magento\Quote\Model\QuoteValidator">
        <plugin name="mironsoft_quote_freshness_validator"
                type="Mironsoft\QuoteApi\Plugin\Model\QuoteFreshnessValidatorPlugin"
                sortOrder="10"/>
    </type>
</config>

8. Asynchronous quote handling in multi-channel scenarios (message queue, POS sync, idempotency)

In multi-channel setups, many requests to the Quote Management API do not run synchronously within the request-response cycle, but go through a message queue. A typical pattern: POS registers publish sale events to a RabbitMQ topic, a consumer processes these messages and creates or updates quotes using the same service contracts as in the synchronous case, just decoupled from the availability of the POS system.

Idempotency is critical here: external systems retry messages whenever delivery success is in doubt, which without a safeguard leads to duplicate quotes being created. The usual solution is an idempotency key, stored as a custom attribute on the quote or in a separate mapping table, before the actual Quote Management API operation is executed. If the consumer receives a message with an already known key, processing is skipped instead of a second quote being created.

Technically, such a consumer is registered via queue_topology.xml, communication.xml and queue_consumer.xml. The actual processing logic in the consumer barely differs from the synchronous variant in section 3: the same interfaces, the same structure, just a different trigger. This is one of the biggest advantages of the Quote Management API as a service contract layer: it is transport-independent and works identically whether invoked synchronously over HTTP or asynchronously over a queue.

9. Performance and caching pitfalls under heavy cart load (quote_id_mask, session handling, race conditions)

The quote_id_mask table maps public, masked cart IDs to internal quote IDs and is read on every GraphQL or REST operation of the Quote Management API. Under heavy concurrent cart load, for example during a campaign with many parallel guest checkouts, this table becomes a hotspot. Missing or poorly chosen indexes then lead to noticeable latency spikes, precisely because every mutation first passes through this lookup before the actual quote is loaded.

A second pitfall concerns the coupling of the PHP session and the active quote ID in the classic storefront flow. Anyone using the Quote Management API directly deliberately bypasses this coupling, which is favorable for scalability, but requires explicit protection against concurrent write access: two simultaneous requests that both call collectTotals() and save() on the same quote can overwrite each other and cause lost updates.

As a safeguard, \Magento\Framework\Lock\LockManagerInterface is recommended for critical write operations on a quote, especially in the asynchronous consumer scenarios from section 8. The quote entity itself should never be cached as a whole; only computed, purely read-only representations such as totals displays are suitable for full-page caching. Anyone blurring this boundary risks stale prices or shipping methods showing up in what looks like an up-to-date cart view.

10. Summary

The Quote Management API in Magento 2 is the only reliable foundation for any integration that needs to control a cart outside the classic checkout flow. CartRepositoryInterface and CartManagementInterface replace fragile direct model access with versioned service contracts. Programmatic cart building, custom totals collectors, GraphQL cart mutations and REST endpoints all access the same underlying layer, whether synchronously or via message queue.

Anyone building POS sync, marketplace integrations or headless checkouts should consistently rely on these service contracts from the start, hook custom validators in cleanly via plugins, and, under heavy load, pay particular attention to quote_id_mask performance and race conditions during concurrent writes. The Quote Management API pays off exactly when multiple channels need to access the same cart state at the same time.

Quote Management API in Magento 2: the essentials at a glance

Service Contracts

CartRepositoryInterface and CartManagementInterface instead of direct model access. A stable, versioned foundation for every cart integration.

Custom Totals

Hook custom pricing logic in via a totals collector instead of hiding prices in product data or manipulating the order afterwards.

GraphQL & REST

GraphQL cart mutations for bundled headless operations, REST for simple integrations such as POS connectivity.

Performance & Idempotency

Keep an eye on quote_id_mask, use idempotency keys for asynchronous sync processes, use locks instead of cache for write access.

11. FAQ: Quote Management API in Magento 2

1What is the Quote Management API in Magento 2?
The set of service contracts around the cart entity Quote: CartRepositoryInterface, CartManagementInterface, GuestCartManagementInterface and associated data interfaces instead of direct model access.
2When CartRepositoryInterface instead of the model directly?
Always outside tightly coupled contexts. Model classes are not part of the stable API surface, service contracts are versioned and protected.
3How do I add products programmatically?
Create an empty quote via createEmptyCart(), load it, call addProduct() per item, set the address and shipping method, then collectTotals() and save().
4Hooking custom pricing logic into quote totals?
Via a totals collector that extends AbstractTotal and is registered via di.xml with a sortOrder. collect() calculates, fetch() returns the segment.
5GraphQL cart mutations vs. REST?
Both use the same Quote Management API. GraphQL bundles operations with a masked cart ID, REST works with separate requests and a customer token or guest cart ID.
6How does the cart merge on login work?
assignCustomer() merges the guest quote with an active customer quote by default. A plugin can customize or suppress this behavior.
7Adding custom cart validation?
Via a plugin on QuoteValidator::validateQuote(), extending the default check with rules like stock availability or maximum quote lifetime.
8Synchronizing POS carts asynchronously?
Via a message queue consumer using the same service contracts as the synchronous case. An idempotency key prevents duplicate quotes on retries.
9What is quote_id_mask?
Maps public masked cart IDs to internal quote IDs and is read on every operation. Under heavy load without suitable indexes, a possible hotspot.
10Avoiding race conditions on concurrent updates?
With LockManagerInterface for critical write operations instead of PHP session coupling. Never cache the quote entity as a whole, only computed displays.

Mironsoft

Magento 2 development, service contracts and API integrations

Need your own Quote Management API integration for your store?

We build headless checkouts, POS connections and marketplace sync on top of the Quote Management API, with clean service contracts, custom totals collectors and robust plugin validation for your Magento 2 store.

API Architecture

CartRepositoryInterface, custom totals and service contracts planned and implemented cleanly

Headless & GraphQL

Cart mutations, PWA checkouts and mobile storefronts connected to the Quote Management API

POS & Marketplace

Asynchronous cart synchronization with idempotency and clean merge logic