Skip to content

Cart Management ​

Flow ID: CF-05 | Module(s): eshop, Cart domain | Complexity: High | Last Updated: 2026-09-29

Business Overview ​

The shopping cart allows customers to collect products before checkout. Ecommercen has two independent cart implementations:

  1. Modern Database Cart (REST API) — persists in shop_cart + shop_cart_item tables, identified by JWT token (customers) or X-Cart-Token header (guests). Used by SPA/headless frontends.
  2. Legacy Session Cart — persists in $_SESSION['cart_contents'], used by the server-rendered storefront.

What customers experience:

  • Add product variants (SKUs) to cart with quantities and optional customizations
  • Automatic quantity merging for identical items (same SKU + same options)
  • Different customization options create separate cart rows
  • Coupons can be applied/removed
  • Guest carts persist across sessions until they expire after an idle lifetime (DB cart) or until session expires (legacy)
  • On login, guest carts merge into the customer's existing cart

Key business behaviors:

  • Stock validation occurs at add-to-cart time (legacy) or at checkout time (modern)
  • Cart items reference product_code_id (specific SKU), not just product ID
  • Gift rules and bundle pricing are evaluated on every cart render
  • Cart contents feed directly into the checkout pipeline

API Reference ​

Modern REST Endpoints (Database Cart) ​

MethodPathAuthDescription
GET/rest/cartGuest or Customer JWTGet current cart with items and totals
POST/rest/cart/itemsGuest or Customer JWTAdd item to cart
POST/rest/cart/items/{id}Guest or Customer JWTUpdate item quantity
DELETE/rest/cart/items/{id}Guest or Customer JWTRemove item
DELETE/rest/cartGuest or Customer JWTClear entire cart
POST/rest/cart/couponGuest or Customer JWTApply coupon code
DELETE/rest/cart/couponGuest or Customer JWTRemove coupon
POST/rest/cart/claimCustomer JWT requiredMerge guest cart into customer cart

Browse endpoints interactively in the API Reference.

Legacy AJAX Endpoints (Session Cart) ​

MethodPathControllerDescription
GET/api/cartAdvApiCartController::index()Get session cart as JSON
POST/api/cart/cartDataAdvApiCartController::cartData()Get cart with VAT context for checkout (see below)
POST/api/cart/updateAdvApiCartController::update()Add/update single item (see below)
POST/api/cart/massUpdateAdvApiCartController::massUpdate()Batch add/update multiple items (see below)
DELETE/api/cart/destroyAdvApiCartController::destroy()Clear session cart

cartData() — Checkout-aware cart fetch ​

Used during checkout to get cart contents with correct VAT calculation based on the customer's address context. Accepts POST body with address/invoice parameters and returns the same cart JSON as index() but with VAT adjusted for the delivery destination. The POST body must be a JSON object — a non-JSON body (e.g. form-encoded) returns 400 Bad Request via denyMalformedBody(), same as update() (fixed in #26).

Parameters (JSON request body):

  • invoice (bool, optional) — whether the order is an invoice (affects VAT)
  • useAddress (string, optional) — which address determines VAT area: ORDER_ADDRESS_ESHOP (default), ORDER_ADDRESS_BILLING, or ORDER_ADDRESS_SHIPPING
  • billingCountry (string, optional) — country code for billing address (default GR)
  • shippingCountry (string, optional) — country code for shipping address (default GR)

Response: Same cart JSON structure as index() — {cartContents, liveData, ...}.

massUpdate() — Batch cart operations ​

Adds or updates multiple cart items in a single request. Each item is validated first; if any item fails validation, the entire batch is rejected and the current cart state is returned with errors. After all items are processed, bundle labeling is re-evaluated and after-add-to-cart recommendations are computed.

Request body (JSON array):

json
[
  {
    "productCodeId": 123,
    "quantity": 2,
    "cartProductCustomizations": null,
    "recommendationId": "abc",
    "recommendationType": "ai_recommendation"
  }
]

Fields per item:

  • productCodeId (int, required) — SKU identifier
  • quantity (int, required) — desired quantity (0 = remove from cart)
  • cartProductCustomizations (object, optional) — customization schema data
  • recommendationId (string, optional) — tracking ID for recommendation attribution
  • recommendationType (string, optional) — recommendation source type (BasketTrackType enum value)

Response: Cart JSON with optional recommendation key containing suggested products.

Side effects: Reports cart events to Manago, Advisable AI, Meta Conversions API, and Matomo when applicable.


Implementation 1: Modern Database Cart ​

Architecture ​

ComponentPathPurpose
REST Controllersrc/Rest/Cart/Controllers/Cart.php8 endpoints; delegates to CartResource with eager-loaded relations
Domain Servicesrc/Domains/Cart/CartService.phpBusiness logic
Cart Entitysrc/Domains/Cart/Cart/Repository/Entity.phpMaps shop_cart
CartItem Entitysrc/Domains/Cart/CartItem/Repository/Entity.phpMaps shop_cart_item
Totals Calculatorsrc/Domains/Cart/CartTotalsCalculator.phpPrice calculation
Cart Repositorysrc/Domains/Cart/Cart/Repository/Repository.phpRead repository; now injected into controller for eager relation loading
Cart Resourcesrc/Rest/Cart/Resources/Cart/Resource.phpCanonical wire shape for the REST cart response; controller delegates here
CartItem Resourcesrc/Rest/Cart/Resources/CartItem/Resource.phpItem serialization; includes nested productCode relation
CartItem Collectionsrc/Rest/Cart/Resources/CartItem/Collection.phpCollection wrapper for cart items
CartTotals Resourcesrc/Rest/Cart/Resources/CartTotals/Resource.phpSchema-only OpenAPI component for totals block

REST Response Structure ​

The controller resolves a 5-level deep relation graph in a single batched CartRepository::get() call, eliminating N+1 queries (src/Rest/Cart/Controllers/Cart.php:52-63, CART_RELATIONS constant):

Cart (shop_cart)
  └── items [ONE_TO_MANY] → CartItem (shop_cart_item)
        └── productCode [BELONGS_TO] → ProductCode (shop_product_codes)
              ├── product [BELONGS_TO] → Product (shop_products)
              │     ├── vat [BELONGS_TO] → Vat
              │     ├── translations [MUI]
              │     └── vendor [BELONGS_TO] → Vendor
              │           └── translations [MUI]
              └── media [ONE_TO_MANY] → Media (shop_product_media)

Each item in the items array includes the nested productCode object (product name, vendor, media, VAT rate) when loaded. Totals are computed by CartTotalsCalculator::calculate() and the response envelope is built via buildCartResponse() (src/Rest/Cart/Controllers/Cart.php:119-164), with the totals block merged at :159-163. The block includes { subtotal: float, netSubtotal: float, itemCount: int, vat: float, giftDiscount: float, giftsNearMiss: array, giftPackagingCost: float, ... } (projectTotals() at :180-186).

Database Schema ​

See ## Data Model for the complete schema tables (shop_cart, shop_cart_item).

Guest Cart Token Lifecycle ​

  1. First request: Client calls POST /rest/cart/items without JWT
  2. Token generated: bin2hex(random_bytes(32)) = 64 random hex characters
  3. Client stores token: Returned in response as cart.cartToken
  4. Subsequent requests: Client sends X-Cart-Token: <token> header
  5. On login: Client calls POST /rest/cart/claim with JWT + old token → merges carts

Additional lifecycle rules (#789):

  • Server-minted only: getOrCreateCart() never adopts a client-supplied token; a new guest cart always gets a fresh bin2hex(random_bytes(32)) (src/Domains/Cart/CartService.php:112-116).
  • Sliding expiry: each use of a live guest cart slides expires_at forward by the guest lifetime, written at most once per SLIDE_GRANULARITY_SECONDS = 3600 (CartService.php:276-292, constant at :38).
  • Expired token = unknown token: resolveCart() and claimCart() look carts up through findLiveGuestCart() (CartService.php:268-274), so an expired token yields cart: null on read and a 404 on claim; add-to-cart creates a new cart under a new token.

Cart Merging on Login ​

CartService::claimCart($cartToken, $customerId):

  • If customer already has a cart: guest items merged into customer cart (smart qty merging), guest cart deleted
  • If customer has no cart: guest cart record updated with customer_id, cart_token cleared and expires_at set NULL (src/Domains/Cart/CartService.php:250-258)
  • An expired guest token is refused by claimCart() exactly like an unknown one (CartService.php:220-226)

Item Deduplication ​

Same product_code_id + identical JSON options = quantity merged. Different options = separate rows. Comparison is exact JSON string match.


Implementation 2: Legacy Session Cart ​

Architecture ​

ComponentPathPurpose
Cart Libraryapplication/libraries/Cart.phpSession-based storage
API Controllerecommercen/api/controllers/AdvApiCartController.phpAJAX endpoints
Cart Resourceecommercen/libraries/AdvCartResource.phpView model builder
Front Controllerecommercen/core/Adv_front_controller.phpCart init on every page
Vue Storeassets/vue/store/actions.jsClient-side response handling, toast feedback, analytics tags

Frontend Response Handling ​

When a user adds or updates a cart item on the storefront, the Vue store handles the AJAX response and decides which success toast to display. The call chain is:

  1. addToCartClicked dispatches api.addToCart(payload) to the legacy /api/cart/update endpoint.
  2. The response and the original payload (which carries the new quantity and productCodeId) are passed to dispatch('reportCartEvents', { productData, item: payload, response }).
  3. reportCartEvents (assets/vue/store/actions.js:187) looks up the previous quantity by finding the matching item in state.cart.cartContents. The lookup applies Number() coercion on both sides (Number(cartItem.productCodeId) === Number(item.productCodeId)) because the server-rendered initial cart state carries string-typed IDs while action payloads are numeric. It then mutates the response object: if the new quantity is less than the previous quantity (including full removal), it sets response.removeFromCart = true; otherwise it sets response.addToCart = true. Analytics tags fire in the same function: GA remove-from-cart fires when item.quantity === 0; GA add-to-cart fires otherwise — independently of the toast flag.
  4. dispatch('setCartData', cartData) reads the flags and shows a green success toast: cart.product_added when response.addToCart is set, cart.product_removed when response.removeFromCart is set.

Session Storage ​

Cart stored in $_SESSION['cart_contents'] as array keyed by row ID (MD5 hash):

[rowId] => {
    rowId: string,           // MD5(productCodeId + JSON(options))
    productCodeId: int,
    productId: int,
    quantity: int,
    options: {
        timestamp: int,
        recommendation: {id, type},
        cartProductCustomizations: array,
        appliedBundles: array
    }
}

Stock Validation ​

Legacy cart validates stock at add time via AdvApiCartController::cartHandle():

  • Calls product_model->getProductIdByProductCodeIdWithStock()
  • Blocks add if requestedQty > availableStock
  • Modern DB cart does NOT validate stock at add time — validation happens at checkout

CartResource (Every Page) ​

On every storefront page load, Front_c constructor initializes CartResource::getInstance() which lazily builds:

  • getCart() — cart items from session
  • getGifts() — matching gift rules for cart contents
  • getGiftRules() — active gift rules including Rule 13 (cheapest free)
  • getCoupon() — auto-applied default coupon if valid
  • getStockErrors() — live stock validation per item. Each issue is raised into one of two cartErrors buckets: server — rendered by the storefront as a blocking 10-second toast (assets/vue/store/actions.js) — or validation — rendered inline next to the offending line item. Codes: 6 (product missing) and 7 (SKU unavailable) → server; 8 (only N in stock) → validation; 9 (backorderable but out of immediate warehouse stock) → server.
    • Configurable demotion (4.114.0): the CART/NON_BLOCKING_ERROR_CODES registry key (pipe-delimited numeric list, e.g. 9 or 7|9) demotes the listed server-class codes to the non-blocking validation class, so backorderable items no longer surface as a blocking toast. An empty/unset value (the default) preserves the previous behavior exactly. Applied at the source in getStockErrors(), so it is consistent across the cart page, the minicart live-data feed, and the shared CartError singleton. See Client Extension Points → Configuration.

Business Rules ​

RuleLegacyModern DB
Storage$_SESSION — lost on session expiryDatabase — guest carts expire after an idle lifetime of guest_cart_lifetime seconds (application/config/config.php:127; defaults to sess_expiration = 604800, env APP_GUEST_CART_LIFETIME); the nightly RemoveExpiredGuestCarts job (application/config/jobs.php:38, 15 1 * * *) deletes expired guest carts. Customer carts have expires_at NULL and persist
Guest identitySession ID (implicit)Cart token (64 hex chars, explicit)
Stock validationAt add-to-cart timeAt checkout time
Cart mergingN/A (session persists through login)Explicit POST /rest/cart/claim
Coupon validationAuto-applied default + validation in cartStored as string, validated at checkout
Bundle pricingApplied via applyBundlePricingToCartLiveData()Not yet implemented
Gift rulesEvaluated every render via AdvCartResourceEarned + near-miss blocks computed per render (src/Rest/Cart/Controllers/Cart.php:161)
Multi-deviceLost on new sessionPersistent if token saved, but only until the guest cart expires (sliding idle expiry)
Zero qty = removeYesYes
Same SKU + same optionsMerged (MD5 row ID)Merged (JSON comparison)

Client Extension Points ​

DI Container Overrides ​

php
// custom/Domains/container.php
$services->alias(
    Advisable\Domains\Cart\CartService::class,
    Custom\Domains\Cart\CartService::class
);

A Custom\…\CartService must accept the constructor argument $guestCartLifetimeSeconds added in #789 (src/Domains/Cart/CartService.php:61).

Legacy Overrides ​

ComponentOverride In
Cart libraryapplication/libraries/Cart.php (already in application/)
Cart API controllerapplication/modules/api/controllers/AdvApiCartController.php
CartResourceapplication/libraries/AdvCartResource.php
Coupon modelapplication/modules/coupons/models/Adv_coupons_model.php
Gift modelapplication/modules/eshop/models/Adv_gifts_model.php

Configuration ​

KeyPurpose
negative_stock (per product)Allow pre-orders beyond stock
cart_limit (per product)Max quantity per cart
PRODUCT_BUNDLES.ENABLEDEnable bundle pricing in cart
GIFT_PACKAGING.ENABLED / GIFT_PACKAGING.COSTGift wrapping option
CART.NON_BLOCKING_ERROR_CODESPipe-delimited list of stock-error codes to demote from the blocking server toast to the inline validation class (default: empty = unchanged behavior). See getStockErrors().
guest_cart_lifetime (config, env APP_GUEST_CART_LIFETIME)Idle lifetime in seconds of a modern DB guest cart (#789). Resolved as guest_cart_lifetime → sess_expiration → 604800 (src/Domains/Cart/container.php:39-43); the CartService constructor throws on a lifetime ≤ 0 (CartService.php:63-65). The value is compiled into cache/container.php, so changing APP_GUEST_CART_LIFETIME needs a container rebuild. Swept nightly by the RemoveExpiredGuestCarts job (application/config/jobs.php:38).

Data Model ​

shop_cart — Modern cart header ​

database/migrations/20260317133831_create_shop_cart_tables.php:12-24

ColumnTypeDescription
idINT UNSIGNED AUTO_INCREMENTPrimary key
customer_idINT UNSIGNED NULLLogged-in customer FK (UNIQUE)
cart_tokenVARCHAR(64) NULLGuest token — 64 hex chars (UNIQUE)
currency_codeVARCHAR(3) DEFAULT 'EUR'Cart currency
coupon_codeVARCHAR(50) NULLApplied discount code
notesTEXT NULLDelivery notes
created_atDATETIME NOT NULL DEFAULT CURRENT_TIMESTAMPCart creation time
updated_atDATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMPLast modification time
expires_atDATETIME NULLGuest idle expiry; NULL on customer carts. Index idx_expires_at (database/migrations/20260923120000_add_expires_at_to_shop_cart.php:46,51)

shop_cart_item — Modern cart line items ​

database/migrations/20260317133831_create_shop_cart_tables.php:29-39

ColumnTypeDescription
idINT UNSIGNED AUTO_INCREMENTPrimary key
cart_idINT UNSIGNEDFK to shop_cart (CASCADE delete)
product_code_idINT UNSIGNEDProduct SKU reference (product_codes.id; no DB-level FK constraint)
qtyINT UNSIGNED DEFAULT 1Quantity
optionsJSON NULLCustomization data (schema-based)
added_atDATETIMEWhen the item was added

Order-time schema ​

For the permanent shop_order_basket table (applied after checkout), see AD-03 Order Management Admin.

Other tables involved ​

TablePurpose
product_codesSKU variants with stock (referenced by product_code_id)
coupons / coupon_rulesCoupon definitions and validation rules
gifts / gift_requirementsGift rule definitions and requirement conditions
tmp_shop_order_basketHistorical cart analytics (cron-maintained snapshots)

Live Testing Notes ​

Legacy cart API (/api/cart/update):

  • Requires JSON body (not form-encoded) — MY_Input::setInputStreamAsPost() does json_decode() on raw input. A non-JSON body (form-encoded, malformed, or a JSON scalar) now returns a 400 Bad Request instead of a fatal array_merge() TypeError; setInputStreamAsPost() returns false and the controller short-circuits via denyMalformedBody() (fixed in #26).
  • Add-to-cart button on storefront sends: POST /api/cart/update?aatcr=true with {"productCodeId": N, "quantity": 1, "invoice": false, "billingCountry": null, "shippingCountry": null, "cartProductCustomizations": {}}
  • Response includes full cart state: cartContents, giftContents, giftRules, cartErrors, defaultCoupon, liveData[]
  • liveData contains per-item: productUrl, productName, images (4 sizes via /mediastream/), weight, points, discount info, oldPrice, finalPrice, stock, barcodes, vendorName
  • aatcr=true query param triggers "after add to cart recommendations" modal

REST cart API (/rest/cart):

  • Uses X-Cart-Token header for anonymous cart identification — returned on first addItem, must be sent back for subsequent requests
  • totals.subtotal is VAT-inclusive (GROSS) — the same basis as the amount POST /rest/checkout/place-order will charge (#563, epic #566 phase 3). It reads the price from the parent shop_product.price column (resolved via productCode.product — product_codes has no price column), then runs it through the shared Advisable\Domains\Product\Pricing\PriceResolver, which composes two collaborators in a fixed order: DiscountResolver (#476) applies the catalogue discount to the NET price first, then VatResolver (#563) converts to GROSS via the legacy VatForOrder seam — reproducing applyVatWithoutFormat() and rounding at the unit before the line multiplies. CartTotalsCalculator::calculate() prefers the already-hydrated productCode.product.vat relation to avoid N+1 lookups and falls back to a repository lookup when the cart item was loaded without the graph; the discount columns and the VAT rate are both base/joined columns on that same row either way, so resolving both costs no extra query. The discount resolver itself reproduces the legacy new_discount rule: OTHER.ENABLE_SPECIAL_DISCOUNTS gates whether a special can apply at all; when it can, both special_from/special_to must be non-NULL and non-empty and now must fall strictly between them, and inside an active window the swap to special_discount_percent is unconditional (a 0% special overrides a non-zero discount_persent rather than falling through to it) — otherwise discount_persent applies. Advisable\Domains\Checkout\OrderBasketBuilder resolves through the same PriceResolver composition at order-placement time, so the quoted subtotal and the persisted basket rows cannot disagree, in either basis.
  • Both addItem and updateItem accept quantity on the wire. The DB column is still named qty (unchanged for the cart snapshot shape on the order), but the REST field is uniformly quantity.
  • Cart claim endpoint (POST /rest/cart/claim) merges guest → customer cart

Known Issues & Security Gaps ​

  1. Bundle pricing not implemented in REST cart — The legacy cart applies bundle discount rules during price computation. The REST CartTotalsCalculator::calculate() reads the unit price from shop_product.price via the hydrated productCode.product relation and applies the catalogue discount (discount_persent / special_discount_percent, #476) through DiscountResolver, but applies no bundle membership check. Bundle discounts are silently absent from REST cart totals. (src/Domains/Cart/CartTotalsCalculator.php:106-142, resolveItemPrice() :155-165)

  2. No stock validation at add-to-cart time (REST path) — CartService::addItem() inserts rows into shop_cart_item without checking product_codes.stock or the product's negative_stock setting. Stock is only enforced at checkout. A customer can add out-of-stock items to their REST cart. (src/Domains/Cart/CartService.php:127-156)

  3. No automatic cart merge at login (REST) — The client must explicitly call POST /rest/cart/claim to merge a guest cart after authentication. Without this call, guest cart items are not transferred to the customer's account. (src/Domains/Cart/CartService.php:220)

Tests ​

Test FileCoverage
tests/Unit/Cart/CartTotalsCalculatorTest.phpUnit tests for price calculation logic
tests/Unit/Cart/CartServiceTest.phpUnit tests for cart add/update/remove operations
tests/Legacy/Cart/AdvCartResourceStockErrorTest.phpLegacy stock error classification and display
tests/Integration/Domains/Cart/**Integration tests for cart domain flows (DB-backed)

Wiki Guides: Gift rule types and configuration — see Gifts Module. For REST API patterns see REST API Modules.

Shared Patterns ​