Skip to main content
Carts manage the collection of items a customer intends to purchase, with dynamic price calculation.

Overview

Carts hold a collection of purchasable items (typically product variants) that a customer intends to order. They belong to users (which relate to customers) and support a single currency each.
Cart prices are dynamically calculated and are not stored in the database. Once a cart is converted to an order, the prices are persisted on the order instead.

Cart Model

Lunar\Models\Cart
FieldTypeDescription
idbigIncrementsPrimary key
user_idforeignId nullableFor guest carts
customer_idforeignId nullable
merged_idforeignId nullableReferences the cart this was merged into
currency_idforeignId
channel_idforeignId
order_idforeignId nullableReferences the order created from this cart
coupon_codestring nullablePromotional coupon code
completed_atdateTime nullable
metajson nullable
created_attimestamp
updated_attimestamp
deleted_attimestamp nullable

Creating a Cart

use Lunar\Models\Cart;

$cart = Cart::create([
    'currency_id' => 1,
    'channel_id' => 2,
]);

Relationships

RelationshipTypeRelated ModelDescription
linesHas manyCartLineThe line items in the cart.
addressesHas manyCartAddressAll addresses associated with the cart.
shippingAddressHas oneCartAddressThe shipping address (filtered by type = shipping).
billingAddressHas oneCartAddressThe billing address (filtered by type = billing).
currencyBelongs toCurrencyThe currency for the cart.
userBelongs toAuth user modelThe authenticated user who owns the cart.
customerBelongs toCustomerThe customer associated with the cart.
ordersHas manyOrderAll orders created from the cart.
draftOrderHas oneOrderThe unplaced (draft) order.
completedOrderHas oneOrderA single placed (completed) order.
completedOrdersHas manyOrderAll placed (completed) orders.
The draftOrder and completedOrder relationships accept an optional order ID parameter to filter to a specific order:
$cart->draftOrder;
$cart->completedOrder;

// Filter to a specific order
$cart->draftOrder($orderId)->first();

Scopes

ScopeDescription
unmergedFilters to carts that have not been merged into another cart (merged_id is null).
activeFilters to carts that have no orders, or only have unplaced (draft) orders.
use Lunar\Models\Cart;

$unmergedCarts = Cart::unmerged()->get();

$activeCarts = Cart::active()->get();

Cart Lines

Each item in a cart is represented by a CartLine. Lines link to a purchasable model (usually a ProductVariant) and track the desired quantity.
Lunar\Models\CartLine
FieldTypeDescription
idbigIncrementsPrimary key
cart_idforeignId
purchasable_typestringMorph type for the purchasable item
purchasable_idunsignedBigIntegerMorph ID for the purchasable item
quantityunsignedInteger
metajson nullable
created_attimestamp
updated_attimestamp

Relationships

RelationshipTypeRelated ModelDescription
cartBelongs toCartThe cart this line belongs to.
purchasableMorph to(polymorphic)The purchasable item, typically a ProductVariant.
taxClassHas one throughTaxClassThe tax class, resolved through the purchasable item.
discountsBelongs to manyDiscountDiscounts applied to this line.

Adding Lines

Use the add method to add a purchasable item to the cart. If the item already exists in the cart, its quantity is updated instead.
$cart->add($purchasable, quantity: 2, meta: [
    'personalization' => 'Happy Birthday!',
]);
Multiple lines can be added at once using addLines:
use Lunar\Models\ProductVariant;

$cart->addLines([
    [
        'purchasable' => ProductVariant::find(1),
        'quantity' => 2,
        'meta' => ['foo' => 'bar'],
    ],
    [
        'purchasable' => ProductVariant::find(2),
        'quantity' => 1,
    ],
]);
Lines can also be created directly via the relationship:
$cart->lines()->create([
    'purchasable_type' => $purchasable->getMorphClass(),
    'purchasable_id' => $purchasable->id,
    'quantity' => 2,
    'meta' => [
        'personalization' => 'Happy Birthday!',
    ],
]);

Updating Lines

// Update a single line's quantity and meta
$cart->updateLine($cartLineId, quantity: 3, meta: ['foo' => 'bar']);

// Update multiple lines at once
$cart->updateLines(collect([
    ['id' => 1, 'quantity' => 5, 'meta' => ['foo' => 'bar']],
    ['id' => 2, 'quantity' => 3],
]));

Removing Lines

// Remove a specific line
$cart->remove($cartLineId);

// Remove all lines
$cart->clear();

Line Validation

When adding, updating, or removing items, a series of validation actions are run (defined in config/lunar/cart.php). These throw a CartException on failure.
use Lunar\Exceptions\Carts\CartException;

try {
    $cart->add($purchasable, quantity: 500);
} catch (CartException $e) {
    $error = $e->getMessage();
}

Exceptions

Lunar throws specific exceptions during cart operations to help identify what went wrong. All cart exceptions extend Lunar\Exceptions\LunarException.

CartException

Lunar\Exceptions\Carts\CartException
The primary exception for cart validation failures. It is thrown by the validation pipeline when adding, updating, or removing cart lines, or when creating an order. It wraps a MessageBag containing one or more error messages.
use Lunar\Exceptions\Carts\CartException;

try {
    $cart->add($purchasable, quantity: 500);
} catch (CartException $e) {
    $e->getMessage(); // Summary of the first error
    $e->errors();     // Returns the full MessageBag
}
When creating an order, the ValidateCartForOrderCreation validator may throw a CartException with one of the following messages:
Error KeyCause
carts.order_existsThe cart already has a completed order.
carts.billing_missingNo billing address has been set on the cart.
carts.billing_incompleteThe billing address is missing required fields (country_id, first_name, line_one, city, postcode).
carts.shipping_missingThe cart contains shippable items but has no shipping address.
carts.shipping_incompleteThe shipping address is missing required fields.
carts.shipping_option_missingThe cart contains shippable items but no shipping option has been selected.

InvalidCartLineQuantityException

Lunar\Exceptions\InvalidCartLineQuantityException
Thrown when attempting to add a purchasable to the cart with a quantity of zero or less.
use Lunar\Exceptions\InvalidCartLineQuantityException;

try {
    $cart->add($purchasable, quantity: 0);
} catch (InvalidCartLineQuantityException $e) {
    // Quantity must be at least 1
}

NonPurchasableItemException

Lunar\Exceptions\NonPurchasableItemException
Thrown when creating or updating a cart line with a model that does not implement the Lunar\Base\Purchasable interface. This is checked automatically by the CartLineObserver.

CartLineIdMismatchException

Lunar\Exceptions\CartLineIdMismatchException
Thrown when attempting to remove a cart line that does not belong to the specified cart.
use Lunar\Exceptions\CartLineIdMismatchException;

try {
    $cart->remove($cartLineId);
} catch (CartLineIdMismatchException $e) {
    // The cart line ID does not belong to this cart
}

DisallowMultipleCartOrdersException

Lunar\Exceptions\DisallowMultipleCartOrdersException
Thrown when calling createOrder() on a cart that already has a completed order, unless allowMultipleOrders: true is passed.
use Lunar\Exceptions\DisallowMultipleCartOrdersException;

try {
    $order = $cart->createOrder();
} catch (DisallowMultipleCartOrdersException $e) {
    // Cart already has a completed order
}

FingerprintMismatchException

Lunar\Exceptions\FingerprintMismatchException
Thrown when the fingerprint passed to checkFingerprint() does not match the cart’s current fingerprint. See Detecting Cart Changes for usage details.

Calculating Totals

Call calculate() to hydrate the cart with computed prices and tax breakdowns.
$cart->calculate();
All monetary values return a Lunar\DataTypes\Price object, providing access to value, formatted, and decimal properties.
To force recalculation (bypassing the cached result), use recalculate:
$cart->recalculate();
To check whether a cart has already been calculated:
if ($cart->isCalculated()) {
    // Totals are available
}

Cart-Level Properties

PropertyDescription
totalThe total price for the cart (including tax).
subTotalThe cart sub total, excluding tax.
subTotalDiscountedThe cart sub total, minus the discount amount.
taxTotalThe total tax applied.
discountTotalThe total discount applied.
shippingSubTotalThe shipping total, excluding tax.
shippingTaxTotalThe tax amount applied to shipping.
shippingTotalThe shipping total, including tax.

Line-Level Properties

After calculation, each cart line is also hydrated with its own totals:
PropertyDescription
unitPricePrice for a single item.
unitPriceInclTaxPrice for a single item, including tax.
totalTotal price for this line.
subTotalSub total, excluding tax.
subTotalDiscountedSub total, minus the discount amount.
taxAmountTax applied to this line.
taxBreakdownCollection of all taxes applied to this line.
discountTotalDiscount applied to this line.
promotionDescriptionDescription of the promotion applied to this line.
foreach ($cart->lines as $cartLine) {
    $cartLine->unitPrice;
    $cartLine->subTotal;
    $cartLine->taxAmount;
    // ...
}

Breakdowns

The cart also provides detailed breakdowns for tax, discounts, and shipping:
// Tax breakdown
foreach ($cart->taxBreakdown->amounts as $taxRate) {
    $taxRate->description;
    $taxRate->price->value;
}

// Discount breakdown
foreach ($cart->discountBreakdown as $discountBreakdown) {
    $discountBreakdown->discount->id;
    $discountBreakdown->price->value;

    foreach ($discountBreakdown->lines as $discountLine) {
        $discountLine->quantity;
        $discountLine->line;
    }
}

// Shipping breakdown
foreach ($cart->shippingBreakdown->items as $shippingBreakdown) {
    $shippingBreakdown->name;
    $shippingBreakdown->identifier;
    $shippingBreakdown->price->formatted();
}

Extending Cart Calculations

To programmatically change cart values (e.g. custom discounts or prices), see Cart Extending.

Cart Addresses

Each cart can have a shipping and billing address, represented by the CartAddress model. These addresses are used when calculating tax breakdowns and shipping costs.
Lunar\Models\CartAddress
FieldTypeDescription
idbigIncrementsPrimary key
cart_idforeignId
country_idforeignId nullable
titlestring nullable
first_namestring nullable
last_namestring nullable
company_namestring nullable
tax_identifierstring nullableTax ID or VAT number
line_onestring nullable
line_twostring nullable
line_threestring nullable
citystring nullable
statestring nullable
postcodestring nullable
delivery_instructionsstring nullable
contact_emailstring nullable
contact_phonestring nullable
typestringEither shipping or billing. Defaults to shipping
shipping_optionstring nullableThe selected shipping option identifier
metajson nullable
created_attimestamp
updated_attimestamp

Relationships

RelationshipTypeRelated ModelDescription
cartBelongs toCartThe cart this address belongs to.
countryBelongs toCountryThe country for this address.

Cached Properties

After the cart is calculated, shipping addresses are hydrated with shipping-related totals:
PropertyDescription
shippingOptionThe resolved ShippingOption data type.
shippingSubTotalThe shipping sub total, excluding tax.
shippingTaxTotalThe tax amount applied to shipping.
shippingTotalThe shipping total, including tax.
taxBreakdownBreakdown of all taxes applied to shipping.

Setting Addresses

Shipping and billing addresses can be set on the cart, which are used when calculating tax breakdowns.
$cart->setShippingAddress([
    'first_name' => null,
    'last_name' => null,
    'line_one' => null,
    'line_two' => null,
    'line_three' => null,
    'city' => null,
    'state' => null,
    'postcode' => null,
    'country_id' => null,
]);

$cart->setBillingAddress([
    'first_name' => null,
    'last_name' => null,
    'line_one' => null,
    'line_two' => null,
    'line_three' => null,
    'city' => null,
    'state' => null,
    'postcode' => null,
    'country_id' => null,
]);
An Address model or a CartAddress model can also be passed:
use Lunar\Models\Address;

$shippingAddress = Address::first();

$cart->setShippingAddress($shippingAddress);

// Use the same address for billing
$cart->setBillingAddress($cart->shippingAddress);
Retrieve addresses via the properties:
$cart->shippingAddress;
$cart->billingAddress;
During a cart’s early lifetime, address information may not yet be available. Some countries don’t display tax until checkout. The address-based tax calculation is designed to handle this: the addresses can be set when they become available.

Shipping Options

A shipping option can be set on the cart after an address has been provided. The available options are determined by the shipping manifest.
$cart->setShippingOption($shippingOption);
To retrieve the currently selected shipping option:
$shippingOption = $cart->getShippingOption();
To check whether the cart contains any shippable items:
if ($cart->isShippable()) {
    // Cart has items that require shipping
}

Shipping Estimates

It may be useful to show an estimated shipping cost before a full address is provided. The getEstimatedShipping method returns the cheapest available shipping option based on partial address data.
use Lunar\Models\Country;

$shippingOption = $cart->getEstimatedShipping([
    'postcode' => '123456',
    'state' => 'Essex',
    'country' => Country::first(),
]);
By default, this estimate is not used in cart total calculations. To include it, pass setOverride: true:
use Lunar\Models\Country;

$shippingOption = $cart->getEstimatedShipping([
    'postcode' => '123456',
    'state' => 'Essex',
    'country' => Country::first(),
], setOverride: true);
When setOverride is enabled, the returned shipping option bypasses other shipping logic in the cart pipelines, but only for that single request.
The override can also be set manually:
use Lunar\DataTypes\ShippingOption;

$cart->shippingOptionOverride = new ShippingOption(/* .. */);

Creating Orders

Once a cart has been calculated and all required information (addresses, shipping, etc.) has been provided, an order can be created from the cart.
$order = $cart->createOrder();
By default, a cart can only have one order. To allow multiple orders from the same cart (e.g. for split shipments), pass allowMultipleOrders:
$order = $cart->createOrder(allowMultipleOrders: true);
To update an existing order instead of creating a new one, pass the order ID:
$order = $cart->createOrder(orderIdToUpdate: $existingOrderId);

Checking Order Readiness

Before attempting to create an order, check whether the cart has sufficient information:
if ($cart->canCreateOrder()) {
    $order = $cart->createOrder();
}
To check whether the cart already has completed (placed) orders:
$cart->hasCompletedOrders(); // bool
To retrieve the current draft order (matching the cart’s fingerprint and total):
$draftOrder = $cart->currentDraftOrder();

Stock Validation

Stock levels can be validated before order creation. This checks each cart line against the available stock for its purchasable item.
use Lunar\Exceptions\Carts\CartException;

try {
    $cart->validateStock();
} catch (CartException $e) {
    // One or more items are out of stock
}

Associating Users and Customers

A user can be associated directly on the cart model. The policy parameter controls how the association behaves when the user already has an existing cart: merge combines the carts, while override replaces the existing one.
$cart->associate($user, policy: 'merge');
A customer can also be associated:
$cart->setCustomer($customer);

Cart Session Manager

The cart session manager is useful when building a traditional Laravel storefront that uses sessions.
The session manager provides a convenient API for managing carts tied to the current user’s session.

Configuration

Cart configuration lives in config/lunar/cart.php:
OptionDescriptionDefault
auth_policyHow to handle merging when a user logs in (merge or override).merge
eager_loadRelationships to eager load by default when calculating cart totals.See below
The default eager_load relationships are:
'eager_load' => [
    'currency',
    'lines.purchasable.taxClass',
    'lines.purchasable.values',
    'lines.purchasable.product.thumbnail',
    'lines.purchasable.prices.currency',
    'lines.purchasable.prices.priceable',
    'lines.purchasable.product',
    'lines.cart.currency',
],
Additional session-specific config is in config/lunar/cart_session.php:
OptionDescriptionDefault
session_keyKey used when storing the cart ID in the session.lunar_cart
auto_createCreate a cart automatically if none exists for the session.false
allow_multiple_orders_per_cartWhether carts can have multiple orders associated to them.false

Getting the Session Instance

Use the facade or inject the interface:
use Lunar\Facades\CartSession;

$cart = CartSession::current();

// Or via dependency injection
use Lunar\Base\CartSessionInterface;

public function __construct(
    protected CartSessionInterface $cartSession
) {
    // ...
}
When current() is called, the behavior depends on the auto_create config. With auto_create set to false (default), null is returned if no cart exists, preventing unnecessary database records.

Managing Lines

use Lunar\Facades\CartSession;
use Lunar\Models\ProductVariant;

// Add a single line
CartSession::add($purchasable, $quantity);

// Add multiple lines (accepts a collection or array)
CartSession::addLines([
    [
        'purchasable' => ProductVariant::find(123),
        'quantity' => 25,
        'meta' => ['foo' => 'bar'],
    ],
    // ...
]);

// Update a single line
CartSession::updateLine($cartLineId, $quantity, $meta);

// Update multiple lines
CartSession::updateLines(collect([
    [
        'id' => 1,
        'quantity' => 25,
        'meta' => ['foo' => 'bar'],
    ],
    // ...
]));

// Remove a line
CartSession::remove($cartLineId);

// Remove all lines
CartSession::clear();

Using a Specific Cart

use Lunar\Facades\CartSession;
use Lunar\Models\Cart;

$cart = Cart::first();
CartSession::use($cart);

Associating a User

use Lunar\Facades\CartSession;

// Associate a cart with a user and set it as the session cart
// The third argument is the auth policy: 'merge' or 'override'
CartSession::associate($cart, $user, 'merge');

Associating a Customer

A customer can be associated directly on the cart model:
$cart->setCustomer($customer);

Forgetting the Cart

Forgetting a cart removes it from the session and soft-deletes it from the database:
use Lunar\Facades\CartSession;

CartSession::forget();
To remove it from the session without deleting:
use Lunar\Facades\CartSession;

CartSession::forget(delete: false);

Shipping Estimates

When using the CartSession manager, shipping estimation parameters can be persisted so they do not need to be passed each time:
use Lunar\Facades\CartSession;
use Lunar\Models\Country;

CartSession::estimateShippingUsing([
    'postcode' => '123456',
    'state' => 'Essex',
    'country' => Country::first(),
]);

// Default behavior: no shipping estimate applied
CartSession::current();

// Apply the shipping estimate set above
CartSession::current(estimateShipping: true);
See Shipping Estimates above for more on how the underlying estimation works.

Handling User Login

When a user logs in, Lunar automatically listens to authentication events and handles cart association. If the user had a guest cart, it will be merged with (or override) any existing cart on their account, depending on the auth_policy config.

Detecting Cart Changes

Carts are dynamic: items, quantities, and prices can change at any moment. To detect whether a cart has been modified (e.g. on a different browser tab during checkout), use the fingerprint system:
use Lunar\Exceptions\FingerprintMismatchException;

$fingerprint = $cart->fingerprint();

try {
    $cart->checkFingerprint('previously_stored_fingerprint');
} catch (FingerprintMismatchException $e) {
    // Cart has changed, refresh it
}
The fingerprint generator class can be customized in config/lunar/cart.php:
return [
    // ...
    'fingerprint_generator' => Lunar\Actions\Carts\GenerateFingerprint::class,
];

Pruning Old Carts

Over time, unused carts accumulate in the database. Lunar can automatically prune carts that have no associated order. Enable pruning in config/lunar/cart.php:
return [
    // ...
    'prune_tables' => [
        'enabled' => false, // Set to true to enable

        'pipelines' => [
            Lunar\Pipelines\CartPrune\PruneAfter::class,
            Lunar\Pipelines\CartPrune\WithoutOrders::class,
            Lunar\Pipelines\CartPrune\WhereNotMerged::class,
        ],

        'prune_interval' => 90, // days
    ],
];
OptionDescriptionDefault
enabledWhether automatic pruning is active.false
prune_intervalNumber of days to retain carts before pruning.90
pipelinesPipeline classes that determine which carts are eligible for removal.

Activity Logging

The Cart model uses Spatie Activity Log to automatically record changes. All attribute changes are logged except for updated_at.

Macros

The Cart model supports macros, allowing custom methods to be added at runtime:
use Lunar\Models\Cart;

Cart::macro('myCustomMethod', function () {
    // ...
});

$cart->myCustomMethod();