Appearance
Cart Management
Flow ID: CF-05 | Module(s): eshop, Cart domain | Complexity: High | Last Updated: 2026-04-28
Business Overview
The shopping cart allows customers to collect products before checkout. Ecommercen has two independent cart implementations:
- Modern Database Cart (REST API) — persists in
shop_cart+shop_cart_itemtables, identified by JWT token (customers) orX-Cart-Tokenheader (guests). Used by SPA/headless frontends. - 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 (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)
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | /rest/cart | Guest or Customer JWT | Get current cart with items and totals |
| POST | /rest/cart/items | Guest or Customer JWT | Add item to cart |
| POST | /rest/cart/items/{id} | Guest or Customer JWT | Update item quantity |
| DELETE | /rest/cart/items/{id} | Guest or Customer JWT | Remove item |
| DELETE | /rest/cart | Guest or Customer JWT | Clear entire cart |
| POST | /rest/cart/coupon | Guest or Customer JWT | Apply coupon code |
| DELETE | /rest/cart/coupon | Guest or Customer JWT | Remove coupon |
| POST | /rest/cart/claim | Customer JWT required | Merge guest cart into customer cart |
Browse endpoints interactively in the API Reference.
Legacy AJAX Endpoints (Session Cart)
| Method | Path | Controller | Description |
|---|---|---|---|
| GET | /api/cart | AdvApiCartController::index() | Get session cart as JSON |
| POST | /api/cart/cartData | AdvApiCartController::cartData() | Get cart with VAT context for checkout (see below) |
| POST | /api/cart/update | AdvApiCartController::update() | Add/update single item (see below) |
| POST | /api/cart/massUpdate | AdvApiCartController::massUpdate() | Batch add/update multiple items (see below) |
| DELETE | /api/cart/destroy | AdvApiCartController::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.
Parameters (POST form data):
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, orORDER_ADDRESS_SHIPPINGbillingCountry(string, optional) — country code for billing address (defaultGR)shippingCountry(string, optional) — country code for shipping address (defaultGR)
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 identifierquantity(int, required) — desired quantity (0 = remove from cart)cartProductCustomizations(object, optional) — customization schema datarecommendationId(string, optional) — tracking ID for recommendation attributionrecommendationType(string, optional) — recommendation source type (BasketTrackTypeenum 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
| Component | Path | Purpose |
|---|---|---|
| REST Controller | src/Rest/Cart/Controllers/Cart.php | 8 endpoints; delegates to CartResource with eager-loaded relations |
| Domain Service | src/Domains/Cart/CartService.php | Business logic |
| Cart Entity | src/Domains/Cart/Cart/Repository/Entity.php | Maps shop_cart |
| CartItem Entity | src/Domains/Cart/CartItem/Repository/Entity.php | Maps shop_cart_item |
| Totals Calculator | src/Domains/Cart/CartTotalsCalculator.php | Price calculation |
| Cart Repository | src/Domains/Cart/Cart/Repository/Repository.php | Read repository; now injected into controller for eager relation loading |
| Cart Resource | src/Rest/Cart/Resources/Cart/Resource.php | Canonical wire shape for the REST cart response; controller delegates here |
| CartItem Resource | src/Rest/Cart/Resources/CartItem/Resource.php | Item serialization; includes nested productCode relation |
| CartItem Collection | src/Rest/Cart/Resources/CartItem/Collection.php | Collection wrapper for cart items |
| CartTotals Resource | src/Rest/Cart/Resources/CartTotals/Resource.php | Schema-only OpenAPI component for totals block |
REST Response Structure
The controller resolves a 4-level deep relation graph in a single batched CartRepository::get() call, eliminating N+1 queries:
Cart (shop_cart)
└── items [ONE_TO_MANY] → CartItem (shop_cart_item)
└── productCode [BELONGS_TO] → ProductCode (shop_product_codes)
├── product [BELONGS_TO] → Product (shop_products)
│ ├── 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) when loaded. Totals are computed by CartTotalsCalculator::calculate() and merged into the response envelope as { subtotal: float, itemCount: int } (src/Rest/Cart/Controllers/Cart.php:41-51, 106-135).
Database Schema
shop_cart table:
| Column | Type | Description |
|---|---|---|
id | INT AUTO_INCREMENT | Primary key |
customer_id | INT NULL | For logged-in customers (UNIQUE) |
cart_token | VARCHAR(64) NULL | For guests — 64 hex chars (UNIQUE) |
currency_code | VARCHAR(3) DEFAULT 'EUR' | Cart currency |
coupon_code | VARCHAR(50) NULL | Applied discount code |
notes | TEXT NULL | Delivery notes |
created_at / updated_at | DATETIME | Timestamps |
shop_cart_item table:
| Column | Type | Description |
|---|---|---|
id | INT AUTO_INCREMENT | Primary key |
cart_id | INT | FK to shop_cart (CASCADE delete) |
product_code_id | INT | Product SKU reference |
qty | INT DEFAULT 1 | Quantity |
options | JSON NULL | Customization data |
added_at | DATETIME | When added |
Guest Cart Token Lifecycle
- First request: Client calls
POST /rest/cart/itemswithout JWT - Token generated:
bin2hex(random_bytes(32))= 64 random hex characters - Client stores token: Returned in response as
cart.cartToken - Subsequent requests: Client sends
X-Cart-Token: <token>header - On login: Client calls
POST /rest/cart/claimwith JWT + old token → merges carts
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_tokencleared
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
| Component | Path | Purpose |
|---|---|---|
| Cart Library | application/libraries/Cart.php | Session-based storage |
| API Controller | ecommercen/api/controllers/AdvApiCartController.php | AJAX endpoints |
| Cart Resource | ecommercen/libraries/AdvCartResource.php | View model builder |
| Front Controller | ecommercen/core/Adv_front_controller.php | Cart init on every page |
| Vue Store | assets/vue/store/actions.js | Client-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:
addToCartClickeddispatchesapi.addToCart(payload)to the legacy/api/cart/updateendpoint.- The response and the original
payload(which carries the newquantityandproductCodeId) are passed todispatch('reportCartEvents', { productData, item: payload, response }). reportCartEvents(assets/vue/store/actions.js:187) looks up the previous quantity by finding the matching item instate.cart.cartContents. The lookup appliesNumber()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 theresponseobject: if the new quantity is less than the previous quantity (including full removal), it setsresponse.removeFromCart = true; otherwise it setsresponse.addToCart = true. Analytics tags fire in the same function: GA remove-from-cart fires whenitem.quantity === 0; GA add-to-cart fires otherwise — independently of the toast flag.dispatch('setCartData', cartData)reads the flags and shows a green success toast:cart.product_addedwhenresponse.addToCartis set,cart.product_removedwhenresponse.removeFromCartis 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 sessiongetGifts()— matching gift rules for cart contentsgetGiftRules()— active gift rules including Rule 13 (cheapest free)getCoupon()— auto-applied default coupon if validgetStockErrors()— live stock validation per item
Business Rules
| Rule | Legacy | Modern DB |
|---|---|---|
| Storage | $_SESSION — lost on session expiry | Database — persists indefinitely |
| Guest identity | Session ID (implicit) | Cart token (64 hex chars, explicit) |
| Stock validation | At add-to-cart time | At checkout time |
| Cart merging | N/A (session persists through login) | Explicit POST /rest/cart/claim |
| Coupon validation | Auto-applied default + validation in cart | Stored as string, validated at checkout |
| Bundle pricing | Applied via applyBundlePricingToCartLiveData() | Not yet implemented |
| Gift rules | Evaluated every render via AdvCartResource | Not yet implemented |
| Multi-device | Lost on new session | Persistent if token saved |
| Zero qty = remove | Yes | Yes |
| Same SKU + same options | Merged (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
);Legacy Overrides
| Component | Override In |
|---|---|
| Cart library | application/libraries/Cart.php (already in application/) |
| Cart API controller | application/modules/api/controllers/AdvApiCartController.php |
| CartResource | application/libraries/AdvCartResource.php |
| Coupon model | application/modules/coupons/models/Adv_coupons_model.php |
| Gift model | application/modules/eshop/models/Adv_gifts_model.php |
Configuration
| Key | Purpose |
|---|---|
negative_stock (per product) | Allow pre-orders beyond stock |
cart_limit (per product) | Max quantity per cart |
PRODUCT_BUNDLES.ENABLED | Enable bundle pricing in cart |
GIFT_PACKAGING.ENABLED / GIFT_PACKAGING.COST | Gift wrapping option |
Data Model
shop_cart — Modern cart header
| Column | Type | Description |
|---|---|---|
id | INT AUTO_INCREMENT | Primary key |
customer_id | INT NULL | Logged-in customer FK (UNIQUE) |
cart_token | VARCHAR(64) NULL | Guest token — 64 hex chars (UNIQUE) |
currency_code | VARCHAR(3) DEFAULT 'EUR' | Cart currency |
coupon_code | VARCHAR(50) NULL | Applied discount code |
notes | TEXT NULL | Delivery notes |
created_at | DATETIME | Cart creation time |
updated_at | DATETIME | Last modification time |
shop_cart_item — Modern cart line items
| Column | Type | Description |
|---|---|---|
id | INT AUTO_INCREMENT | Primary key |
cart_id | INT | FK to shop_cart (CASCADE delete) |
product_code_id | INT | Product SKU reference (FK to shop_product_codes.id) |
qty | INT DEFAULT 1 | Quantity |
options | JSON NULL | Customization data (schema-based) |
added_at | DATETIME | When the item was added |
shop_order_basket — Permanent order items
| Column | Type | Description |
|---|---|---|
id | INT AUTO_INCREMENT | Primary key |
order_id | INT | FK to shop_order |
product_code_id | INT | SKU reference at time of order |
quantity | INT | Ordered quantity |
price | DECIMAL(10,4) | Unit price at time of order |
discount | DECIMAL(5,2) | Discount applied |
vat_percent | DECIMAL(5,2) | VAT rate applied |
is_gift | TINYINT(1) DEFAULT 0 | Whether this is a gift item |
Other tables involved
| Table | Purpose |
|---|---|
product_codes | SKU variants with stock (referenced by product_code_id) |
coupons / coupon_rules | Coupon definitions and validation rules |
gifts / gift_requirements | Gift rule definitions and requirement conditions |
tmp_shop_order_basket | Historical cart analytics (cron-maintained snapshots) |
Live Testing Notes
Legacy cart API (/api/cart/update):
- Requires JSON body (not form-encoded) —
MY_Input::setInputStreamAsPost()doesjson_decode()on raw input. Form-encoded POST causesarray_merge()TypeError. - Add-to-cart button on storefront sends:
POST /api/cart/update?aatcr=truewith{"productCodeId": N, "quantity": 1, "invoice": false, "billingCountry": null, "shippingCountry": null, "cartProductCustomizations": {}} - Response includes full cart state:
cartContents,giftContents,giftRules,cartErrors,defaultCoupon,liveData[] liveDatacontains per-item: productUrl, productName, images (4 sizes via/mediastream/), weight, points, discount info, oldPrice, finalPrice, stock, barcodes, vendorNameaatcr=truequery param triggers "after add to cart recommendations" modal
REST cart API (/rest/cart):
- Uses
X-Cart-Tokenheader for anonymous cart identification — returned on first addItem, must be sent back for subsequent requests totals.subtotalreads the price from the parentshop_product.pricecolumn (resolved viaproductCode.product—product_codeshas no price column).CartTotalsCalculator::calculate()prefers the already-hydrated relation to avoid N+1 lookups and falls back toProductCodeRepository::get($id, ['product' => []])when the cart item was loaded without the graph. Discounts (discount_persent,special_discount_percent) and VAT are not applied at this stage.- Both
addItemandupdateItemacceptquantityon the wire. The DB column is still namedqty(unchanged for the cart snapshot shape on the order), but the REST field is uniformlyquantity. - Cart claim endpoint (
POST /rest/cart/claim) merges guest → customer cart
Known Issues & Security Gaps
Gift rules not implemented in REST cart —
AdvCartResourcein the legacy layer evaluates gift rules on every render, but the REST Cart controller has no equivalent.CartTotalsCalculatorreturns onlysubtotalanditemCount; free-gift eligibility is not computed. (src/Domains/Cart/CartTotalsCalculator.php)Bundle pricing not implemented in REST cart — The legacy cart applies bundle discount rules during price computation. The REST
CartTotalsCalculatorreads the unit price fromshop_product.pricevia the hydratedproductCode.productrelation (src/Domains/Cart/CartTotalsCalculator.php:35-53), but applies no bundle membership check. Bundle discounts are silently absent from REST cart totals.No stock validation at add-to-cart time (REST path) —
CartService::addItem()inserts rows intoshop_cart_itemwithout checkingshop_product.stockorshop_product.negative_stock. Stock is only enforced at checkout. A customer can add out-of-stock items to their REST cart. (src/Domains/Cart/Cart/Service.php)Cart merge on login not implemented in REST layer — The legacy frontend merges the guest cart into the authenticated cart on login (
ecommercen/eshop/models/Adv_cart_model.php). The REST auth flow has no equivalent merge step. Guest cart items are lost when a customer logs in via the REST API.
Related Flows
- CF-02 Product Detail — "Add to Cart" originates from PDP
- CF-06 Order Preview — cart feeds into checkout
- CF-07 Order Confirmation — final cart parsing before payment
- CF-12 Promotions — cart promo recommendations via
cartPromo() - CF-13 Coupons — coupon validation and application
- CF-14 Gift Rules — gift matching and Rule 13
- CF-24 Product Bundles — bundle pricing in cart
- CF-33 Multi-Currency — cart currency handling
- CF-36 Cart Customizations — customization schema validation
Wiki Guides: Gift rule types and configuration — see Gifts Module. For REST API patterns see REST API Modules.
Shared Patterns
- SY-24 Email Dispatch — email notifications on cart abandonment recovery
- SY-27 Deferred Tasks — cart analytics snapshots processed as deferred tasks
- SY-07 Cache Management — cached product data used during cart rendering