Skip to main content
A cart holds the purchasable items a customer intends to buy, together with the addresses and shipping selection needed to calculate totals and, eventually, create an order.

Overview

A cart belongs to a channel and a currency, optionally to an authenticated user and a customer, and holds a collection of lines, each pointing at a purchasable item (typically a product variant).
Cart totals (subTotal, total, taxTotal, and so on) are calculated at runtime by a pipeline and are not stored on the carts table. They are populated as public properties on the Lunar\Core\Models\Cart instance when calculate() runs, and are instances of Lunar\Core\DataObjects\PriceValue. Once a cart is converted to an order, the resulting totals are persisted on the order.

Cart model

Fields

Relationships

Scopes

Lunar\Core\Models\Cart uses SoftDeletes. This is not a lifecycle state on the cart itself — it lets order-tied carts hang around for replay and audit rather than being permanently removed.

Cart line model

Fields

Relationships

purchasable_type / purchasable_id on a cart line are always set — every cart line points at a real purchasable. This is unlike an order line, where the same morph is nullable for self-describing lines such as shipping or ad-hoc charges. A cart never produces a purchasable-less line.
Don’t confuse the purchasable morph relation with Lunar\Core\Enums\SellingPolicy, the always / in_stock / in_stock_or_on_backorder mode stored on ProductVariant::$selling_policy (renamed from purchasable in v2). The two are unrelated: the morph is what is in the line, the selling policy is whether the underlying variant can currently be sold, and it’s what canBeFulfilledAtQuantity() checks when a line is added or updated (see Adding and updating lines).

Cart address model

Fields

Relationships

A cart holds at most one address per type — adding an address of a given type replaces any existing one of that type.

Retrieving and using carts

Lunar\Core\Facades\CartSession manages the cart for the current visitor, backed by Lunar\Core\Managers\CartSessionManager. It is bound scoped in the container, so under Octane or a queue worker each request or job gets its own instance — see Session-scoped, not a singleton below.
By default CartSession::current() does not create a cart. Set lunar.cart_session.auto_create to true to have it create one automatically, or call CartSession::manager(), which always returns a cart.
Configuration lives in packages/core/config/cart_session.php: When no cart ID is in the session and the visitor is authenticated, CartSession looks up the user’s latest unmerged, active cart before creating a new one, so a returning logged-in user picks their existing cart back up.

Creating a cart directly

Carts can also be created directly, bypassing the session, which is useful for APIs or background jobs:

Setting the channel and currency

Setting the currency on an existing cart updates currency_id on the cart and drops its loaded currency and lines relations, so the next calculate() prices in the new currency.

Associating a user

$policy is either merge (the default) or override. On merge, the user’s existing active cart is merged into the given cart via MergeCart; matching lines (same purchasable and meta) have their quantities combined, others are copied across. On override, the user’s existing cart is marked as merged into itself and abandoned in favor of the given cart. This mirrors lunar.cart.auth_policy, applied automatically by CartSessionAuthListener when a user logs in.

Forgetting the session cart

Removes the cart ID from the session. Deletes the cart itself unless $delete is passed as false or lunar.cart_session.delete_on_forget is false.

Adding and updating lines

Cart lines are also managed through verb methods on the Cart model itself, which is what CartSession delegates to.
add() runs the configured lunar.cart.validators.add_to_cart validators (quantity, stock, and channel/customer-group availability by default) before adding or incrementing the line, then refreshes and recalculates the cart. If a line for the same purchasable and meta already exists, its quantity is incremented rather than a new line being created.
Stock validation calls $purchasable->canBeFulfilledAtQuantity($quantity), which for a Lunar\Core\Models\ProductVariant reads its Lunar\Core\Enums\SellingPolicy (Always, InStock, InStockOrOnBackorder) — a variant with policy Always is always fulfillable regardless of stock.

Addresses and tax zone

$address accepts either an array of address fields or a model implementing Lunar\Core\Contracts\Addressable. Setting an address replaces any existing address of the same type on the cart. Setting the shipping address clears any manually-set tax_zone_id by default (pass $clearTaxZone = false to keep it), so tax resolution falls back to the address-derived zone.

Shipping

Shipping options are resolved through Lunar\Core\Facades\ShippingManifest — see Extending Shipping for how carriers register options. CartSession::getShippingOptions() returns the options available for the session’s current cart.

Discounts

Coupon codes and cart-level discounts are applied during calculation, not through a dedicated verb method — set coupon_code on the cart and recalculate:
See Discounts for how discounts are matched and applied. After calculation, $cart->discounts, $cart->discountTotal, $cart->discountBreakdown, $cart->promotions, and $cart->freeItems describe what was applied.

Calculating totals

calculate() runs the pipeline configured at lunar.cart.pipelines.cart, in order:
  1. Lunar\Core\Pipelines\Cart\CalculateLines — runs the lunar.cart.pipelines.cart_lines pipeline (GetUnitPrice) over each line, and any registered Lunar\Core\Modifiers\CartLineModifier classes
  2. Lunar\Core\Pipelines\Cart\ApplyShipping — resolves and applies the shipping option
  3. Lunar\Core\Pipelines\Cart\CalculateShippingSubTotal
  4. Lunar\Core\Pipelines\Cart\ApplyDiscounts — matches and applies eligible discounts
  5. Lunar\Core\Pipelines\Cart\CalculateTax
  6. Lunar\Core\Pipelines\Cart\Calculate — sums line totals, discounts, and shipping into the cart-level totals
Each stage populates public properties on the cart (and its lines) as Lunar\Core\DataObjects\PriceValue instances: A PriceValue exposes ->value (the integer minor-unit amount), ->decimal(), and ->format(), resolved against the cart’s currency. It also supports add(), subtract(), multiply(), and clampToZero() for combining amounts in custom pipeline stages.
Custom calculation logic is added as a Lunar\Core\Modifiers\CartModifier (cart-level) or Lunar\Core\Modifiers\CartLineModifier (line-level), each exposing calculating() / calculated() hooks (and, for line modifiers, subtotalled()). See Extending Carts.

Converting a cart to an order

createOrder() recalculates the cart, runs the lunar.cart.validators.order_create validators (ValidateCartForOrderCreation by default — quantity, stock, and address/shipping completeness), then delegates to the CreatesOrder action contract to create or update the draft order tied to the cart, mark any used discounts as consumed, and return the resulting Lunar\Core\Models\Order.
CartSession::createOrder() is the session-aware equivalent — it creates the order from the session’s current cart and, by default, calls forget() afterwards. A cart’s fingerprint() produces a hash of its lines, user, currency, and coupon code, used to detect whether a cart has changed since a price or order was last calculated for it.

Pruning carts

Lunar\Core\Console\Commands\PruneCarts deletes carts (and their lines and addresses) matched by the lunar.cart.prune_tables.pipelines pipeline. By default this excludes carts with orders and carts merged into another cart, and only targets carts older than lunar.cart.prune_tables.prune_interval days (default 90). Pruning is disabled by default (lunar.cart.prune_tables.enabled); schedule the command once enabled.

Session-scoped, not a singleton

Lunar\Core\Contracts\CartSession is bound scoped in the container, not singleton — it memoizes the current cart for the life of one request or job. Under Octane or a queue worker, a singleton binding would persist across requests and leak one visitor’s cart into the next; scoped is discarded when the request or job ends.