Skip to main content
The shopper/cart package handles everything between “Add to cart” and “Place order”. It manages cart lines, validates stock, applies discounts, calculates taxes, and converts completed carts into orders. All through a clean, testable API. Every monetary value is stored in cents as an unsigned integer. A product priced at $25.00 is stored as 2500.

How It Works

The cart system has five main pieces:
  1. CartManager handles cart operations: add, update, remove, apply coupons, calculate totals
  2. CartSessionManager persists the current cart in the session for storefront use
  3. Pipeline system calculates totals through a configurable chain of pipes (subtotals → discounts → taxes → total)
  4. DiscountValidator enforces coupon rules: eligibility, usage limits, zones, minimum amounts
  5. CreateOrderFromCartAction converts a cart into an order within a database transaction

Cart Manager

The CartManager is the primary API for all cart operations. It is registered as a singleton and injected with the CartPipelineRunner.
use Shopper\Cart\CartManager;

$manager = resolve(CartManager::class);

Adding Items

Pass any model that implements Priceable, typically a Product or ProductVariant. The manager looks up the unit price from the purchasable’s price list using the cart’s currency.
$line = $manager->add($cart, $product);

$line = $manager->add($cart, $product, quantity: 3);

$line = $manager->add($cart, $variant, metadata: [
    'gift_wrap' => true,
    'message' => 'Happy birthday!',
]);
Adding the same purchasable twice increments the existing line’s quantity instead of creating a duplicate.
$manager->add($cart, $product, quantity: 2); // quantity: 2
$manager->add($cart, $product, quantity: 3); // quantity: 5

Stock Validation

Every add() and update() call checks available stock. If the purchasable implements Stockable and allow_backorder is false, an InsufficientStockException is thrown when the requested quantity exceeds available inventory.
use Shopper\Cart\Exceptions\InsufficientStockException;

try {
    $manager->add($cart, $product, quantity: 100);
} catch (InsufficientStockException $e) {
    $e->purchasable; // The product or variant
    $e->available;   // Current stock level
    $e->requested;   // Quantity that was requested
}
Products with allow_backorder set to true bypass stock checks entirely.

Updating a Line

$updated = $manager->update($cart, $lineId, [
    'quantity' => 5,
]);

$updated = $manager->update($cart, $lineId, [
    'metadata' => ['gift_wrap' => false],
]);

Removing Lines

$manager->remove($cart, $lineId);

$manager->clear($cart);

Applying Coupons

The applyCoupon() method validates that the discount code exists in the database before setting it on the cart. The actual discount calculation happens during the pipeline.
use Shopper\Cart\Exceptions\InvalidDiscountException;

try {
    $manager->applyCoupon($cart, 'SUMMER20');
} catch (InvalidDiscountException $e) {
    // Discount code not found
}

$manager->removeCoupon($cart);
Removing a coupon also deletes all line adjustments that were created by the discount pipeline.

Adding Addresses

use Shopper\Core\Enum\AddressType;

$manager->addAddress($cart, AddressType::Shipping, [
    'first_name' => 'John',
    'last_name' => 'Doe',
    'address_1' => '123 Main St',
    'city' => 'New York',
    'postal_code' => '10001',
    'country_id' => $countryId,
]);

$manager->addAddress($cart, AddressType::Billing, $billingData);
If an address of the same type already exists, it is updated instead of duplicated.

Calculating Totals

use Shopper\Cart\Pipelines\CartPipelineContext;

$context = $manager->calculate($cart);

$context->subtotal;      // Sum of line subtotals (before discounts/tax)
$context->discountTotal; // Total discount amount
$context->taxTotal;      // Total tax amount
$context->total;         // Final total
$context->taxInclusive;  // Whether tax is included in prices

Completed Cart Guard

All mutating operations (add, update, remove, clear, applyCoupon, removeCoupon) throw CartCompletedException if the cart has already been completed. This prevents modifications after an order has been placed.
use Shopper\Cart\Exceptions\CartCompletedException;

$cart->update(['completed_at' => now()]);

$manager->add($cart, $product); // throws CartCompletedException

Session Management

The CartSessionManager handles cart persistence in the HTTP session. Use it in your storefront to retrieve or create the current customer’s cart.

Using the Facade

use Shopper\Cart\Facades\Cart;

$cart = Cart::current();

$cart = Cart::create();

$cart = Cart::create(['channel_id' => $channelId, 'zone_id' => $zoneId]);

Cart::use($existingCart);

Cart::associate($user);

Cart::forget();

Behavior

  • current() returns null if no cart exists in the session and auto_create is false
  • current() returns null if the session cart has been completed (it won’t return stale carts)
  • create() stores the new cart’s ID in the session and sets the currency_code from shopper_currency()
  • associate() sets the customer_id on the current cart — call this after login
  • forget() removes the cart ID from the session without deleting the cart itself

Models

Cart

Shopper\Cart\Models\Cart
ColumnTypeNullableDescription
idbigintPrimary key
customer_idbigintYesFK to users
channel_idbigintYesFK to shopper_channels
zone_idbigintYesFK to shopper_zones
currency_codestringISO currency code (e.g. USD)
coupon_codestringYesApplied discount code
completed_attimestampYesSet when cart is converted to order
metadatajsonYesCustom data
created_attimestamp
updated_attimestamp

Relationships

$cart->lines;            // HasMany<CartLine>
$cart->addresses;        // HasMany<CartAddress>
$cart->customer;         // BelongsTo<User>
$cart->channel;          // BelongsTo<Channel>
$cart->zone;             // BelongsTo<Zone>

Methods

$cart->isCompleted();       // bool — checks if completed_at is set
$cart->shippingAddress();   // ?CartAddress — address with type Shipping
$cart->billingAddress();    // ?CartAddress — address with type Billing

CartLine

Shopper\Cart\Models\CartLine
ColumnTypeNullableDescription
idbigintPrimary key
cart_idbigintFK to cart
purchasable_typestringMorph type (Product, ProductVariant, etc.)
purchasable_idbigintMorph ID
quantityunsigned intItem quantity
unit_price_amountunsigned intPrice per unit in cents
metadatajsonYesCustom data

Relationships

$line->cart;           // BelongsTo<Cart>
$line->purchasable;    // MorphTo — Product, ProductVariant, etc.
$line->adjustments;    // HasMany<CartLineAdjustment>
$line->taxLines;       // HasMany<CartLineTaxLine>

CartAddress

Shopper\Cart\Models\CartAddress
ColumnTypeNullableDescription
idbigintPrimary key
cart_idbigintFK to cart
typestringshipping or billing (AddressType enum)
country_idbigintYesFK to shopper_countries
first_namestringYes
last_namestring
companystringYes
address_1stringStreet address
address_2stringYesApartment, suite, etc.
citystring
statestringYes
postal_codestring
phonestringYes
The full_name accessor combines first_name and last_name.

CartLineAdjustment

Shopper\Cart\Models\CartLineAdjustment
ColumnTypeNullableDescription
idbigintPrimary key
cart_line_idbigintFK to cart line
discount_idbigintYesFK to discount
amountunsigned intDiscount amount in cents
codestringYesCoupon code

CartLineTaxLine

Shopper\Cart\Models\CartLineTaxLine
ColumnTypeNullableDescription
idbigintPrimary key
cart_line_idbigintFK to cart line
tax_rate_idbigintYesFK to tax rate
codestringTax code
namestringTax name (e.g. “VAT”)
ratedecimal(8,4)Tax rate (e.g. 20.0000 for 20%)
amountunsigned intTax amount in cents

Calculation Pipeline

Cart totals are calculated through a pipeline of configurable steps. Each pipe receives a CartPipelineContext, performs its calculation, and passes it to the next pipe.

Default Pipeline

use Shopper\Cart\Pipelines\CalculateLines;
use Shopper\Cart\Pipelines\ApplyDiscounts;
use Shopper\Cart\Pipelines\CalculateTax;
use Shopper\Cart\Pipelines\Calculate;

// config/shopper/cart.php
'pipelines' => [
    'cart' => [
        CalculateLines::class,
        ApplyDiscounts::class,
        CalculateTax::class,
        Calculate::class,
    ],
],

Pipeline Steps

1. CalculateLines — Computes the subtotal for each line (unit_price_amount × quantity) and sums them into $context->subtotal. 2. ApplyDiscounts — If the cart has a coupon_code, validates the discount through DiscountValidator and calculates the discount amount. For percentage discounts, each line gets (lineSubtotal × rate / 100). For fixed amount discounts, the amount is distributed proportionally across lines. Creates CartLineAdjustment records. 3. CalculateTax — Resolves the shipping address country and calculates tax for each line using the core TaxCalculator. The taxable amount per line is (lineSubtotal - discountAmount). Creates CartLineTaxLine records. 4. Calculate — Computes the final total. If tax is inclusive: total = subtotal - discountTotal. If tax is exclusive: total = subtotal - discountTotal + taxTotal. The minimum total is always 0.

Custom Pipeline

You can replace or extend the pipeline in config/shopper/cart.php. Add a custom pipe to handle shipping costs, loyalty points, or any other calculation:
'pipelines' => [
    'cart' => [
        CalculateLines::class,
        ApplyDiscounts::class,
        App\Cart\Pipes\ApplyShippingCost::class,
        CalculateTax::class,
        Calculate::class,
    ],
],
Your custom pipe must implement __invoke(CartPipelineContext $context, Closure $next):
namespace App\Cart\Pipes;

use Closure;
use Shopper\Cart\Pipelines\CartPipelineContext;

final class ApplyShippingCost
{
    public function __invoke(CartPipelineContext $context, Closure $next): mixed
    {
        // Your calculation logic here

        return $next($context);
    }
}

Discount Validation

When a coupon is applied during the pipeline, the DiscountValidator runs a series of checks before the discount is calculated. Each check produces a clear, translatable error message.

Validation Rules

RuleConditionError Key
Activediscount->is_active must be truediscount.not_active
Starteddiscount->start_at must be in the pastdiscount.not_started
Not expireddiscount->end_at must be null or in the futurediscount.expired
Usage limitTotal uses must be under the global limitdiscount.usage_limit_reached
Per-user limitCustomer must not have exceeded their per-user limitdiscount.already_used
EligibilityIf restricted to specific customers, the cart customer must be in the listdiscount.customer_not_eligible
Requires loginIf eligibility is set, the cart must have a customer_iddiscount.requires_login
ZoneIf the discount has a zone_id, it must match cart->zone_iddiscount.not_available_in_zone
Minimum amountCart subtotal must meet the minimum purchase requirementdiscount.min_amount_not_reached
Minimum quantityTotal line quantity must meet the minimum quantity requirementdiscount.min_quantity_not_reached

Discount Application Modes

Discounts support two application scopes via discount->apply_to:
  • Order (DiscountApplyTo::Order) — applies to all cart lines
  • Specific products — applies only to lines matching the products/variants configured on the discount
And two discount types:
  • Percentage — each applicable line gets (lineSubtotal × value / 100)
  • Fixed amount — the fixed amount is distributed proportionally across applicable lines based on their share of the total subtotal

Order Conversion

The CreateOrderFromCartAction converts a completed cart into an order. This is the bridge between the storefront cart and the order management system.
use Shopper\Cart\Actions\CreateOrderFromCartAction;

$action = resolve(CreateOrderFromCartAction::class);
$order = $action->execute($cart);

What Happens

The entire operation runs inside a database transaction with a FOR UPDATE lock on the cart:
  1. Calculates totals — runs the full pipeline to get final amounts
  2. Creates order addresses — copies cart shipping/billing addresses to OrderAddress records
  3. Creates the order — with price_amount, tax_amount, currency_code, and all foreign keys
  4. Creates order items — one per cart line, including the discount amount from adjustments
  5. Creates order tax lines — via CreateOrderTaxLinesAction
  6. Increments discount usage — if a coupon was applied, increments total_use on the discount
  7. Marks cart as completed — sets completed_at to prevent further modifications
  8. Dispatches CartCompleted event — with the cart and the created order
If the cart has already been completed, CartCompletedException is thrown before any work begins. The FOR UPDATE lock prevents race conditions from concurrent checkout attempts.

Events

EventDispatched WhenProperties
CartCompletedCart is converted to an orderCart $cart, Order $order
CouponAppliedCoupon code is set on a cartCart $cart, string $code
CouponRemovedCoupon code is cleared from a cartCart $cart
use Shopper\Cart\Events\CartCompleted;
use Shopper\Cart\Events\CouponApplied;
use Shopper\Cart\Events\CouponRemoved;

Configuration

Publish the configuration file:
php artisan vendor:publish --tag=shopper-cart-config
// config/shopper/cart.php

return [
    'pipelines' => [
        'cart' => [
            CalculateLines::class,
            ApplyDiscounts::class,
            CalculateTax::class,
            Calculate::class,
        ],
    ],

    'session' => [
        'key' => 'shopper_cart',     // Session key for storing cart ID
        'auto_create' => false,      // Auto-create cart when current() is called
    ],

    'prune_after_days' => 30,        // Days before abandoned carts are pruned

    'models' => [
        'cart' => Cart::class,       // Swappable via model contracts
        'cart_line' => CartLine::class,
    ],
];

Model Swapping

The Cart and CartLine models implement core contracts (CartContract, CartLineContract) and use the HasModelContract trait. You can replace them with your own models:
// config/shopper/cart.php
'models' => [
    'cart' => App\Models\Cart::class,
    'cart_line' => App\Models\CartLine::class,
],
Your custom models must implement the corresponding contracts from Shopper\Core\Models\Contracts.

Abandoned Carts

A cart is considered “abandoned” when it has items but no activity for a configurable period. Shopper tracks this automatically and provides both an admin interface and programmatic tools to manage abandoned carts.

Admin Panel

The admin panel includes an Abandoned Carts page under the Orders section. It lists all carts that are not completed, have at least one line item, and have been inactive for longer than the configured threshold. Administrators can view cart contents, the customer (if authenticated), and the associated channel.

Configuration

Two config keys in config/shopper/cart.php control abandoned cart behavior:
'abandoned_after_minutes' => 60,  // Cart is "abandoned" after 60 minutes of inactivity
'prune_after_days' => 30,         // Abandoned carts are deleted after 30 days

Querying Abandoned Carts

To find abandoned carts programmatically, for example to send recovery emails:
use Shopper\Cart\Models\Cart;

$abandonedCarts = Cart::query()
    ->whereNull('completed_at')
    ->whereHas('lines')
    ->whereNotNull('customer_id')
    ->where('updated_at', '<', now()->subMinutes(
        config('shopper.cart.abandoned_after_minutes', 60)
    ))
    ->with(['customer', 'lines.purchasable'])
    ->get();

Pruning

Abandoned carts that are too old are cleaned up with a scheduled command:
php artisan shopper:prune-carts
php artisan shopper:prune-carts --days=7
By default, carts that haven’t been updated in the last 30 days (configurable via prune_after_days) are deleted. Only carts where completed_at is null are pruned. Completed carts are kept as historical records tied to their orders. Schedule the command in your routes/console.php:
use Illuminate\Support\Facades\Schedule;

Schedule::command('shopper:prune-carts')->daily();

Storefront Implementation

This section shows how to build a complete cart experience in your storefront. The examples are based on the patterns used in the Shopper demo store.

Cart Session Helper

A common pattern is to create a cartSession() helper that handles cart creation with the correct zone and channel. This avoids repeating the creation logic across your controllers and components:
// app/helpers.php

use Shopper\Cart\CartSessionManager;
use Shopper\Cart\Models\Cart;
use Shopper\Core\Models\Channel;

function cartSession(): Cart
{
    $session = resolve(CartSessionManager::class);
    $cart = $session->current();

    if (! $cart) {
        $defaultChannel = Channel::query()->scopes('default')->first();

        $cart = $session->create([
            'currency_code' => shopper_currency(),
            'channel_id' => $defaultChannel?->id,
            'zone_id' => session('zone_id'),
            'customer_id' => auth()->id(),
        ]);
    }

    return $cart;
}
Register the helper in your composer.json:
{
    "autoload": {
        "files": ["app/helpers.php"]
    }
}

Associating Cart on Login

When a guest adds items to their cart and then logs in, the cart should be associated with their account. Register a listener for the Login event in your AppServiceProvider:
use Illuminate\Auth\Events\Login;
use Illuminate\Support\Facades\Event;
use Shopper\Cart\CartSessionManager;

public function boot(): void
{
    Event::listen(Login::class, function (Login $event): void {
        resolve(CartSessionManager::class)->associate($event->user);
    });
}

Add to Cart Action

Create a dedicated action class that handles adding both products and variants to the cart:
namespace App\Actions\Cart;

use App\Models\Product;
use App\Models\ProductVariant;
use Shopper\Cart\CartManager;
use Shopper\Cart\Models\CartLine;

final readonly class AddToCart
{
    public function __construct(
        private CartManager $cartManager,
    ) {}

    public function handle(Product $product, ?ProductVariant $variant = null): CartLine
    {
        $purchasable = $variant ?? $product;

        return $this->cartManager->add(cartSession(), $purchasable);
    }
}

Livewire Cart Component

Here is a complete Livewire component for displaying and managing the cart as a slide-over panel:
namespace App\Livewire;

use App\Models\ProductVariant;
use Illuminate\Contracts\View\View;
use Illuminate\Support\Collection;
use Livewire\Attributes\On;
use Livewire\Component;
use Shopper\Cart\CartManager;
use Shopper\Cart\CartSessionManager;
use Shopper\Cart\Models\CartLine;

final class ShoppingCart extends Component
{
    public int|float $subtotal = 0;

    /** @var Collection<int, CartLine> */
    public Collection $items;

    public function mount(): void
    {
        $this->loadCart();
    }

    #[On('cartUpdated')]
    public function loadCart(): void
    {
        $cart = resolve(CartSessionManager::class)->current();

        if (! $cart) {
            $this->items = collect();
            $this->subtotal = 0;

            return;
        }

        $cart->load('lines.purchasable');
        $cart->lines->loadMorph('purchasable', [
            ProductVariant::class => ['product'],
        ]);

        $this->items = $cart->lines;

        $context = resolve(CartManager::class)->calculate($cart);
        $this->subtotal = $context->subtotal;
    }

    public function removeFromCart(int $lineId): void
    {
        resolve(CartManager::class)->remove(cartSession(), $lineId);

        $this->loadCart();
        $this->dispatch('cartUpdated');
    }

    public function render(): View
    {
        return view('livewire.shopping-cart');
    }
}
The corresponding Blade view displays each cart line with its product details and a remove button:
<div>
    @if ($items->isEmpty())
        <p>Your cart is empty.</p>
    @else
        <ul>
            @foreach ($items as $line)
                <li class="flex items-center justify-between py-4">
                    <div>
                        <p class="font-medium">
                            @if ($line->purchasable instanceof \App\Models\ProductVariant)
                                {{ $line->purchasable->product->name }} - {{ $line->purchasable->name }}
                            @else
                                {{ $line->purchasable->name }}
                            @endif
                        </p>
                        <p class="text-sm text-gray-500">
                            Qty: {{ $line->quantity }}
                            &middot;
                            {{ shopper_money_format($line->unit_price_amount, cartSession()->currency_code) }}
                        </p>
                    </div>
                    <button wire:click="removeFromCart({{ $line->id }})">Remove</button>
                </li>
            @endforeach
        </ul>

        <div class="border-t pt-4">
            <p class="font-medium">
                Subtotal: {{ shopper_money_format($subtotal, cartSession()->currency_code) }}
            </p>
        </div>
    @endif
</div>

Cart Header Button

A small component that displays the cart item count in the navigation. It listens for the cartUpdated event to refresh automatically:
namespace App\Livewire;

use Illuminate\Contracts\View\View;
use Livewire\Attributes\On;
use Livewire\Component;
use Shopper\Cart\CartSessionManager;

final class ShoppingCartButton extends Component
{
    public int $count = 0;

    public function mount(): void
    {
        $this->updateCount();
    }

    #[On('cartUpdated')]
    public function updateCount(): void
    {
        $cart = resolve(CartSessionManager::class)->current();

        $this->count = $cart ? $cart->lines->sum('quantity') : 0;
    }

    public function render(): View
    {
        return view('livewire.shopping-cart-button');
    }
}

Displaying Cart Totals

Use the CartManager::calculate() method to get the full breakdown. The returned CartPipelineContext contains all computed values:
$context = resolve(CartManager::class)->calculate($cart);
<div>
    <p>Subtotal: {{ shopper_money_format($context->subtotal, $cart->currency_code) }}</p>

    @if ($context->discountTotal > 0)
        <p>Discount: -{{ shopper_money_format($context->discountTotal, $cart->currency_code) }}</p>
    @endif

    @if ($context->taxTotal > 0)
        <p>Tax: {{ shopper_money_format($context->taxTotal, $cart->currency_code) }}</p>
    @endif

    <p class="font-bold">Total: {{ shopper_money_format($context->total, $cart->currency_code) }}</p>
</div>

Applying a Coupon

Validate and apply a discount code to the cart. The actual discount calculation happens during the pipeline when calculate() is called:
use Shopper\Cart\Exceptions\InvalidDiscountException;

try {
    resolve(CartManager::class)->applyCoupon($cart, $couponCode);
} catch (InvalidDiscountException $e) {
    return back()->with('error', $e->getMessage());
}

Setting Checkout Addresses

Add shipping and billing addresses to the cart before converting to an order:
use Shopper\Core\Enum\AddressType;

$manager = resolve(CartManager::class);

$manager->addAddress($cart, AddressType::Shipping, [
    'first_name' => $request->first_name,
    'last_name' => $request->last_name,
    'address_1' => $request->address,
    'city' => $request->city,
    'postal_code' => $request->postal_code,
    'country_id' => $request->country_id,
]);

$manager->addAddress($cart, AddressType::Billing, $billingData);

Converting to Order

When the customer completes checkout, convert the cart into an order. The action runs inside a database transaction, calculates final totals, creates order records, and marks the cart as completed:
use Shopper\Cart\Actions\CreateOrderFromCartAction;
use Shopper\Cart\Facades\Cart;

$order = resolve(CreateOrderFromCartAction::class)->execute(Cart::current());

Cart::forget();
After converting the cart to an order, call Cart::forget() to clear the session. The cart is marked as completed and cannot be modified, but the session should be cleaned up so Cart::current() returns null. See the Payments page for processing the order payment.