Install and register. The package is a drop-in continuation of the (now abandoned) gloudemans/shoppingcart / Crinsane package, so any tutorial you find for that one applies almost verbatim.
composer require mindscms/laravelshoppingcart
On Laravel 5.5+ the Minds\ShoppingCart\ShoppingCartServiceProvider and the Cart alias are auto-discovered. On older versions add them to config/app.php:
'providers' => [
Minds\ShoppingCart\ShoppingCartServiceProvider::class,
],
'aliases' => [
'Cart' => Minds\ShoppingCart\Facades\Cart::class,
],
Publish what you need. You only need the migration if you plan to persist carts to the database (see store()/restore() below). Publishing the config is worth doing on day one so the currency formatting and DB connection are explicit rather than implicit.
php artisan vendor:publish --provider="Minds\ShoppingCart\ShoppingCartServiceProvider"
php artisan migrate
Where to look first. Read src/Cart.php — it is a single, readable class and is effectively the whole API surface. Then src/CartItem.php (and its options bag), the published config/shoppingcart.php, and src/DatabaseStorage.php if you're using persistence. The README is thin compared to the source; don't fight it.
First use case — an add-to-cart endpoint. The array form of add() is the one to learn, because it accepts tax, weight, options and an Eloquent model:
use Minds\ShoppingCart\Facades\Cart;
public function store(Product $product)
{
Cart::add([
'id' => $product->id,
'name' => $product->name,
'qty' => request('qty', 1),
'price' => $product->price,
'tax' => 21, // percentage for this line
'options' => ['size' => 'L'],
'associatedModel' => $product, // exposed later as $item->model
]);
return redirect()->route('cart.index');
}
Then render it — content() is a Collection keyed by rowId:
@foreach (Cart::content() as $rowId => $item)
<tr>
<td>{{ $item->name }} ({{ $item->options->size }})</td>
<td>{{ $item->qty }}</td>
<td>{{ $item->price }}</td>
<td>{{ $item->subtotal }}</td>
<td>
<form method="POST" action="{{ route('cart.update', $rowId) }}">
@csrf @method('PATCH')
<input name="qty" value="{{ $item->qty }}">
</form>
</td>
</tr>
@endforeach
<p>Items: {{ Cart::count() }} — Total: {{ Cart::total() }}</p>
That's enough to ship a working cart in an afternoon.
Keep controllers thin, wrap the cart in a service. Cart is a facade over a singleton, so it's easy to spray calls across views. Put the mutations in one CartService (add, update, remove, clear) and let controllers validate and delegate. Views should only ever read (content(), count(), total()).
Merge-on-add is automatic — rely on it, then verify it. Adding the same id with identical options increments the existing row; different options create a new row. This is why the options array matters: it's part of the row identity, not just metadata.
Cart::add(['id' => 1, 'name' => 'Tee', 'qty' => 1, 'price' => 25, 'options' => ['size' => 'L']]);
Cart::add(['id' => 1, 'name' => 'Tee', 'qty' => 1, 'price' => 25, 'options' => ['size' => 'L']]);
// one row, qty 2 — same options hash
Quantity updates. update() accepts either a scalar (quantity) or an array of attributes, and is the standard "change quantity" endpoint:
Cart::update($rowId, request('qty')); // qty only
Cart::update($rowId, ['qty' => 3, 'price' => 19.99]); // qty + reprice
Cart::remove($rowId);
Cart::destroy(); // empty the whole cart
Because this is user input, validate it (integer|min:1|max:99) in the request class, not in the package.
Attach Eloquent models for the read path. associatedModel is the single most useful integration point: it lets you render images, slugs and stock without a second lookup per row.
$item->model; // the Product you passed in
$item->model->image; // still lazy-loaded per row — eager-load where it hurts
If you're rendering a cart page for 20 lines, hydrate instead of relying on N+1: Product::whereIn('id', Cart::content()->pluck('id'))->get()->keyBy('id').
Money: use the *Float() accessors for logic, the string accessors for display. Cart::total(), subtotal(), tax() return formatted strings according to your config. Use Cart::totalFloat(), Cart::subtotalFloat(), Cart::taxFloat() when you need a number (shipping thresholds, free-shipping checks, passing to a payment gateway).
$shipping = Cart::subtotalFloat() >= 50 ? 0 : 4.95;
Tax and discounts. Set a default rate globally once (Cart::setGlobalTax(21) in a service provider or middleware) and override per line with the tax key at add time. The package also ships a condition API (Cart::addCondition(...), Cart::removeCondition(...), Cart::getCondition(...)) that can target the whole cart or a row — it's genuinely useful for promotions, but the docs are sparse, so read src/Cart.php and unit-test your condition before trusting the numbers.
Persistence across sessions — store() / restore() is manual, not automatic. Session storage is the default; the DB layer is an explicit save/restore of a serialized cart. The standard pattern:
// on login or before logout
Cart::store(auth()->id());
// on next visit (middleware or login controller)
Cart::restore(auth()->id());
// on logout
Cart::destroy();
Wrap the login-side restore in a try/catch — restoring into a non-empty cart, or restoring with a stale rowId, is a common source of "duplicate line item" bugs. The cleanest version is: destroy() the merge target first, then restore().
Checkout: snapshot, don't trust. The cart is a session blob. At checkout, re-fetch every product by id, re-read the current price and stock, compare with $item->price, and only then write order_items. Then Cart::destroy() inside the same DB transaction that creates the order.
DB::transaction(function () {
$order = Order::create([...]);
foreach (Cart::content() as $item) {
$product = Product::lockForUpdate()->findOrFail($item->id);
abort_if($product->stock < $item->qty, 422, 'Out of stock');
$order->items()->create([... 'unit_price' => $product->price ...]);
}
Cart::destroy();
});
Events for side effects. When event dispatching is enabled in the config, the cart fires add / update / remove / destroy (and store/restore) events carrying the CartItem. Use them for analytics, abandoned-cart timestamps, or "recently viewed" — never for anything the checkout depends on.
Multiple carts. Recent versions expose Cart::instance('wishlist') alongside the default instance, each with its own session key and DB rows. Confirm it exists in the Cart class of the version you've installed before designing around it; it's a convenient way to separate a wishlist from a live cart.
SESSION_DRIVER=array the cart vanishes between requests (and in some test setups). With the cookie driver you're capped around 4KB — a dozen line items with chunky options and the cart silently truncates. Use file/redis/database for anything real.total() is a string, not a number. Cart::total() with the default config returns "1,234.56". Doing * 1.1 on it gives you nonsense. Reach for totalFloat() / subtotalFloat() / taxFloat().decimal columns) when you write the order.update($rowId, 0) deletes the row. It's documented behaviour, not a bug, but it surprises people writing "decrement" buttons. Guard against it, or embrace it for a "remove" affordance.rowId is a hash of id + options, not a stable identifier. It changes if the option array changes shape or key order. Never store a rowId in the DB or in a URL you expect to survive a cart edit — resolve it from content() at request time and 404 on misses.search() returns items; get() returns one. Cart::search(['id' => 5]) gives you a Collection of matching CartItems (handy for "is this product already in the cart?"). Cart::get($rowId) returns a single item or null.store()/restore() fail quietly-ish. Restoring an identifier that doesn't exist throws; restoring into a non-empty cart merges. Always destroy() or store() deliberately, and test the logout→login→cart round trip.EventServiceProvider never fires, check the events key in the published config before you start debugging your listener registration.restore()/store() if you must, or better, keep cart logic in HTTP-land.Cart::destroy() in setUp() — the singleton and session persist between test methods in the same class and produce order-dependent failures that look like package bugs.composer.json, be prepared to fork, and treat it as a session cart, not as an order engine. The abstraction is small enough (Cart.php is one file) that forking or vendor-patching is a realistic option if you hit a wall.How can I help you explore Laravel packages today?