marshmallow/cart
| Install | |
|---|---|
composer require marshmallow/cart |
|
| Latest Version: | 6.1.0 |
| PHP: | ^8.3 |
| License: | MIT |
| Last Updated: | Sep 17, 2026 |
| Links: | GitHub · Packagist |

Ecommerce Cart
A cart, order and discount engine for Laravel storefronts. The core has no admin-panel dependency (no Nova, no Filament); it builds on marshmallow/payable for payments and marshmallow/addressable for addresses.
- A session-backed shopping cart with line combining, quantity handling, per-line price snapshots and an explicit confirm → pay → convert lifecycle.
- An immutable
Pricevalue object: integer cents, VAT-inclusive canonical, withnet + vat === grossguaranteed. - Discounts (fixed amount, percentage, free shipping) with prerequisites, eligibility rules, usage limits and stacking, booked per VAT rate.
- Shipping methods the customer picks, priced with a free-over-threshold, plus fee lines for payment surcharges.
- Orders built from the snapshot a payment was started with — lines, customer, addresses, shipping method and vouchers frozen onto the order — idempotent on the cart id, with guarded status transitions.
- A full event stream, stock hooks, cart merge on login and abandoned-cart housekeeping.
Requires PHP ^8.3, Laravel ^12.0 || ^13.0 and marshmallow/payable ^4.3.
Installation
composer require marshmallow/cart
php artisan vendor:publish --tag="cart-config"
php artisan vendor:publish --tag="cart-migrations"
php artisan migrate
Coming from a Nova-based release (5.x)? Publish the guarded upgrade migration as well and read UPGRADE.md:
php artisan vendor:publish --tag="cart-upgrade-migrations"
php artisan migrate
Register the middleware (aliased as cart) on your storefront routes so every request carries the current cart:
Route::middleware('cart')->group(function () {
// storefront routes
});
Inside those routes the cart is available as Cart::getFromRequest() (or $request->attributes->get('cart')). A visitor without a cart gets an unsaved one; it persists itself on the first add(), so nothing is written for bots and bounces.
Configuration
| Key | Default | Description |
|---|---|---|
models |
package models | Every model the cart touches, swappable per entry with a subclass. product is the fallback model for lines without a purchasable type and must implement Purchasable. |
currency |
EUR |
ISO 4217 code stamped onto Price value objects. A cart holds one currency. |
locale |
nl_NL |
Locale for the money formatter. |
prices_include_vat |
true |
Whether back-office prices are entered gross. The Price object is gross-canonical either way. |
default_vat_percentage |
21.0 |
Rate a zero-valued discount line falls back to. |
customer_guard |
web |
Guard whose logins and logouts touch the cart. |
middleware |
alias cart, no exclusions |
Middleware class, alias and the request paths it should skip. |
listeners |
merge / disconnect / convert | Login, logout and payment_paid listeners; set any to [] to opt out. |
payable.convert_on_paid |
true |
Create the order from the payment snapshot when payable reports a payment paid. |
stock |
both true |
check_on_add: consult isAvailableForPurchase() when a line is added or grows. check_on_checkout: check every line in confirm() and report shortages after payment. |
abandoned |
30 / 90 days | Days until a quiet cart is flagged as abandoned (flag_abandoned), and until it is permanently pruned. |
commands |
CleanCartsCommand |
The housekeeping command class. |
Usage
Make your product purchasable
The cart never reaches into your product model directly. Implement the four-method contract on any Eloquent model — a product, a subscription, a gift card; the cart stores the model's morph type next to its key, so one cart can hold lines from several models:
use Marshmallow\Ecommerce\Cart\Contracts\Purchasable;
use Marshmallow\Ecommerce\Cart\Models\ShoppingCart;
use Marshmallow\Ecommerce\Cart\Support\Price;
class Product extends Model implements Purchasable
{
public function getPurchasableKey(): int|string
{
return $this->getKey();
}
public function getPurchasableName(): string
{
return $this->name;
}
public function getPurchasablePrice(int $quantity = 1, ?ShoppingCart $cart = null): Price
{
return Price::fromGross($this->price_cents, 21.0, 'EUR');
}
public function isAvailableForPurchase(int $quantity, ?ShoppingCart $cart = null): bool
{
return $this->stock >= $quantity;
}
}
Implement HasPurchasableCategories as well when you want discounts scoped to categories. Point config('cart.models.product') at your main product model; it is the fallback for lines written before purchasable types existed.
getPurchasablePrice() is your pricing hook: $quantity for tiered prices (the cart re-asks whenever a line's quantity changes) and $cart for customer-specific price lists. isAvailableForPurchase() receives the line's total quantity when a line is added or grows, and again per line in confirm(). Lines added with a caller-chosen price (addCustom()) are never repriced or stock-checked.
Work with the cart
use Marshmallow\Ecommerce\Cart\Facades\Cart;
$cart = Cart::get();
$line = $cart->add($product, quantity: 2);
$cart->add($product, quantity: 1, meta: ['size' => 'L']); // meta makes it a separate line
$cart->setQuantity($line, 5); // 0 removes the line
$cart->remove($line);
$cart->clear();
$cart->applyDiscount($discount); // throws DiscountException when not allowed
$cart->removeDiscount('CODE'); // one code; no argument clears them all
$cart->discounts(); // the applied Discount models
$cart->getSubtotal(); // product lines, gross cents
$cart->getTotalAmount(); // grand total incl. shipping, discount and fees
$cart->getTotalVatAmount();
Every line in a cart shares one currency; a line in another currency throws CurrencyMismatchException.
Discounts stack when every code involved is marked is_combinable; a percentage code then compounds over the already-discounted subtotal. A non-combinable code replaces whatever is applied (and vice versa), the same code is refused twice, and every applied code is re-evaluated on each cart change — a code that no longer qualifies drops off. Applying a code is transactional, so a rejected replacement leaves the existing codes untouched. A discount books one negative line per VAT rate it spans (pro rata, cent-exact), so the VAT on the discount mirrors the VAT on what it discounts; a free-shipping code follows the shipping line's rate.
Checkout: confirm, pay, convert
$cart->confirm(); // freeze the cart for payment; throws while the customer can still act
$cart->reopen(); // after a failed or canceled payment
confirm() refuses an empty cart (EmptyCartException), a line whose purchasable can no longer supply its quantity (PurchasableUnavailableException) and a voucher that no longer qualifies with what is known by now — usage limits, once-per-customer with the customer's e-mail (DiscountException). It throws and leaves the cart open, so the storefront can show the message. Once confirmed, every mutation throws CartLockedException; addresses and the note are covered too.
With marshmallow/payable the cart is the payable:
$url = $cart->startPayment($paymentType); // confirms first, then hands the customer to the provider
Payable freezes $cart->getPayableSnapshot() onto the payment the moment it starts. When the provider reports the payment paid, the package's ConvertPaidPaymentToOrder listener creates the order from that snapshot — never from the live cart. A cart that was reopened and changed after the payment started cannot leak into the order; a paid amount that does not match the snapshot creates no order and fires PaymentSnapshotMismatch; a second payment for an already converted cart returns the existing order and fires DuplicatePaymentDetected so you can refund it.
Without payable, convert yourself once you know the payment settled:
$order = $cart->convertToOrder(expectedTotalAmount: $paidCents); // idempotent on the cart id
$order = Order::createFromSnapshot($snapshot, $cart, $payment); // from a snapshot you kept
Conversion happens after money changed hands, so it never throws for a product that sold out or a voucher that stopped qualifying in the meantime: the order is created as paid for and StockShortageDetected / DiscountInvalidAtConversion tell you to follow up. The cart is stamped converted_at and closed for good (CartConvertedException); the session, the middleware and the login merge hand out a fresh cart from then on.
What an order remembers
An order stands on its own. Besides its lines it carries customerSnapshot(), shippingAddressSnapshot(), invoiceAddressSnapshot() (every address column plus country name and code), shippingMethodSnapshot(), discountsSnapshot() and a snapshot_fingerprint. Edit the address book, rename the product or change the shipping method a year later: the order still says what was sold. The *_id columns remain as references; the money columns and snapshots are guarded against mass assignment.
$order->markAsCompleted(); // OrderStatusChanged
$order->markAsRefunded(); // OrderStatus::Refunded + OrderRefunded (once)
$cart = $order->toNewCart(); // re-order: fresh cart at current prices, skipping what vanished or sold out
Status changes are guarded: pending → canceled, completed or refunded; completed → refunded; canceled → pending; a refund is final. Anything else throws InvalidOrderStatusTransitionException; repeating the current status is a no-op. A canceled or refunded order hands its voucher redemptions back.
An open cart can re-ask every purchasable for its current price, for example when a customer returns to a cart that sat overnight:
$changed = $cart->refreshPrices(); // repriced lines; fires ItemPriceChanged per line
Prices
Every amount is an immutable, cents-based value object. The gross amount is canonical, so net + vat === gross always holds:
use Marshmallow\Ecommerce\Cart\Support\Price;
$price = Price::fromGross(12100, 21.0); // € 121,00 incl. 21% VAT
$price->amountExcludingVat; // 10000
$price->vatAmount(); // 2100
Price::fromNet(10000, 21.0); // same price, built from the net side
$price->multiply(3); // line total
$price->percentage(10); // basis for a 10% discount
$price->format(); // "€ 121,00" in the configured locale
Shipping and fees
The customer picks a shipping method; the method prices itself against the cart, with an optional free-over-threshold (free_from_amount, measured against the product subtotal). Which methods apply is decided by their ShippingMethodCondition bands (inclusive subtotal ranges; a method without conditions applies to any cart), valid_from / valid_till, and sort (lowest wins as the default). The picked method stays selected through cart changes for as long as it is still active and applicable; then the default takes over. A single fee line carries a payment surcharge and is replaced, never stacked:
$cart->selectShippingMethod($method); // null clears shipping (e.g. pickup); ShippingMethodSelected
$cart->setFee('Toeslag VISA', Price::fromGross(150, 21));
$cart->setFee('Toeslag VISA', null); // remove the surcharge again; FeeChanged
Override hasExcludedShipping() in a cart subclass to rule shipping out for a cart (a download-only order, say). An empty cart never carries a shipping line.
Events
Hook into the full lifecycle without touching package code:
| Event | Fires when |
|---|---|
CartCreated |
a fresh cart is minted for the session |
ItemAdded, ItemQuantityChanged, ItemRemoved |
product lines change |
ItemPriceChanged |
refreshPrices() or a tier crossing repriced a line |
DiscountApplied, DiscountRejected |
a voucher lands (with its total and per-rate lines) or is refused (with the reason) |
ShippingCalculated |
a shipping method is (re)priced for the cart |
ShippingMethodSelected, FeeChanged |
the customer picked a method / a fee was set or cleared |
CartConfirmed, CartReopened |
the cart froze for payment / was taken back |
CartMerged |
a guest cart's product lines fold into the user's open cart at login; the guest cart is soft-deleted with its lines |
CustomerCreated |
a prospect is promoted to a customer |
OrderCreated |
the paid cart became an order |
StockShortageDetected, DiscountInvalidAtConversion |
an order was created although a line sold out / a voucher stopped qualifying after payment |
PaymentSnapshotMismatch, DuplicatePaymentDetected |
a paid amount did not match its snapshot (no order) / a second payment settled for a converted cart |
OrderStatusChanged, OrderRefunded |
an order moved status / was refunded |
CartAbandoned |
housekeeping flags a quiet cart |
Extend the models
Every model resolves through config('cart.models.*'), so a host application can subclass any of them — to add multi-tenancy, extra relations or your own logic. Subclasses may carry any name; relations use explicit foreign keys.
// config/cart.php
'shopping_cart' => \App\Models\Shop\ShoppingCart::class,
namespace App\Models\Shop;
class ShoppingCart extends \Marshmallow\Ecommerce\Cart\Models\ShoppingCart
{
public function hasExcludedShipping(): bool
{
return $this->productItems()->every(fn ($line) => $line->meta['digital'] ?? false);
}
}
Login and logout listeners react only to config('cart.customer_guard'); an admin signing into another guard in the same browser never touches the customer's cart.
Housekeeping
Schedule the abandoned-cart command to flag quiet carts (firing CartAbandoned per cart) and permanently prune the long-expired ones, lines and orphaned prospects included. A flagged cart that sees activity again is unflagged; every line change counts as activity. Confirmed and converted carts are never touched.
Schedule::command('ecommerce:clean-carts')->daily();
Dutch translations for the customer-facing discount messages ship with the package; publish them with the cart-translations tag to override.
Testing
composer test
The suite runs on Pest with a 100% coverage gate; composer analyse runs PHPStan and composer lint runs Pint.
Changelog
See CHANGELOG.md; upgrade notes live in UPGRADE.md.
Contributing
Pull requests are welcome. Please open an issue first to discuss substantial changes.
Security Vulnerabilities
Please report security vulnerabilities by email to stef@marshmallow.dev rather than via the public issue tracker.
Credits
License
The MIT License (MIT).
Related Packages
Shopping Cart library for Laravel 5 that uses Database instead of Sessions
Package set to provide shop or e-commerce functionality (such as CART, ORDERS, T...
Laravel 5 wrapper for the Woocommerce REST API
Package set to provide shop or e-commerce functionality (such as CART, ORDERS, T...