A Cart is a persistent basket with an optional polymorphic owner (a user) or a guest token, plus its own currency. Add variants and read its price through the same engine orders use:
use Illuminate\Support\Str;
use RoundlyConsulting\Shops\Cart\Cart;
use RoundlyConsulting\Shops\Facades\Shops;
// A signed-in customer's cart:
$cart = Cart::create([
'currency' => 'EUR',
'owner_type' => $user->getMorphClass(),
'owner_id' => $user->id,
]);
// A guest cart, found again by its token (the package ships no cart-merge logic):
$guest = Cart::create(['currency' => 'EUR', 'token' => (string) Str::uuid()]);
$guest = Cart::where('token', $token)->first();
$item = Shops::cart($cart)->add($variant, 2); // snapshots name/sku/price/tax class; same variant increments
$cart->add($variant); // the same, as a model method
Shops::cart($cart)->update($item, 3); // set a line's quantity
Shops::cart($cart)->update($item, 0); // 0 removes the line (returns null)
Shops::cart($cart)->remove($item);
Shops::cart($cart)->clear(); // every line; the cart itself stays
$cart->items; // Collection<CartItem>
Shops::cart($cart)->subtotal(); // Money — sum of line totals, before discount
Shops::cart($cart)->price(); // Price DTO — using the stored coupon_code
Shops::cart($cart)->price('WELCOME10'); // Price DTO — preview another code
$cart->update(['coupon_code' => 'WELCOME10']);
$cart->owner; // ?Model
$cart->isGuest(); // true when owner_id is null- add() snapshots the variant’s name (or the product name when the variant has none), SKU, price and tax class. Adding the same variant again increments the existing line. $cart->add() is the same call as a model method.
- The cart row is locked while a line is added, so a double-clicked add lands on one line.
- A variant priced in another currency throws CurrencyMismatch and the cart is left unchanged.
- update() and remove() refuse a line of another cart with ForeignItemException before anything is written.
- price() uses shops.pricing.price_type, the cart’s shop for tax lookup, no country, zero shipping and the discount from the bound DiscountResolver. It never records a redemption.
- add(), update() and remove() drop a loaded items relation, so the next price() or subtotal() reads the change.
Cart actions
The handle calls these actions, all under RoundlyConsulting\Shops\Actions\Cart. The cart always comes first, and the foreign-line check lives in the actions, so the action form enforces it too:
| Action | Signature | Behaviour |
|---|---|---|
AddToCartAction | execute(Cart $cart, ProductVariant $variant, int $quantity = 1): CartItem | Behind cart()->add() and $cart->add(); runs under the cart’s row lock, so a double-clicked add lands on one line. |
UpdateCartItemAction | execute(Cart $cart, CartItem $item, int $quantity): ?CartItem | Sets the quantity; 0 deletes the line and returns null; negative or over the maximum throws; another cart’s line throws ForeignItemException. |
RemoveFromCartAction | execute(Cart $cart, CartItem $item): void | Soft-deletes the line; another cart’s line throws ForeignItemException. |
ClearCartAction | execute(Cart $cart): Cart | Soft-deletes every line and returns the refreshed cart. |
use RoundlyConsulting\Shops\Actions\Cart\AddToCartAction;
use RoundlyConsulting\Shops\Actions\Cart\ClearCartAction;
use RoundlyConsulting\Shops\Actions\Cart\RemoveFromCartAction;
use RoundlyConsulting\Shops\Actions\Cart\UpdateCartItemAction;
$item = app(AddToCartAction::class)->execute($cart, $variant, 2); // CartItem
app(UpdateCartItemAction::class)->execute($cart, $item, 3); // the cart comes first
app(UpdateCartItemAction::class)->execute($cart, $item, 0); // 0 removes the line (returns null)
app(RemoveFromCartAction::class)->execute($cart, $item); // or remove it explicitly
app(ClearCartAction::class)->execute($cart); // remove every line, returns the fresh cart
Shops::cart($otherCart)->update($item, 2); // ForeignItemException — not that cart's lineQuantities
A quantity is a whole number of items from 1 to 32 767 (Support\Quantity::MAX, the range of the quantity columns). Anything else — zero or negative in add() or at checkout, negative in update(), a fraction, or a line that would grow past the maximum — throws InvalidQuantityException before anything is written. CartItem and order Item guard quantity on every write too:
use RoundlyConsulting\Shops\Support\Quantity;
Quantity::MAX; // 32767 — the range of the quantity columns
Shops::cart($cart)->add($variant, 0); // InvalidQuantityException
Shops::cart($cart)->update($item, -1); // InvalidQuantityException
$item->update(['quantity' => '3']); // integer strings (request input) are accepted
$item->update(['quantity' => 1.5]); // InvalidQuantityExceptionShow your open-source love
This package is free and MIT-licensed. If it saves you time, a one-off donation or a Patreon membership keeps it maintained, tested and documented.
More ways to support, including cryptoBy donating, you agree to our donation terms.
Want this built into your product?
We integrate our packages into custom Laravel and AI builds. Tell us what you're working on and we'll reply within 48 hours.