bumbummen99/shoppingcart
A Laravel shopping cart package for managing cart items, quantities, prices, taxes, shipping, and coupons. It supports session and database storage, multiple cart instances, item conditions, and events, with a simple facade API for adding, updating, and removing products.
Install and publish
composer require bumbummen99/shoppingcart:^4.2
php artisan vendor:publish --provider="Gloudemans\Shoppingcart\ShoppingcartServiceProvider" --tag="config"
php artisan vendor:publish --provider="Gloudemans\Shoppingcart\ShoppingcartServiceProvider" --tag="database"
php artisan migrate
Laravel 5.5+ auto-discovers the service provider. The package is a maintained fork of Crinsane/LaravelShoppingcart, so the namespace is still Gloudemans\Shoppingcart — the facade alias is Cart → Gloudemans\Shoppingcart\Facades\Cart. Only publish/run the database migration if you plan to use the store()/restore() persistence feature; the default cart lives entirely in the session.
Pin ^4.2 rather than *: 4.2.0 changes the on-disk format of persisted carts (see Gotchas), so an unpinned composer update on a live store can break restore() without a code change on your side.
Where to look first
config/cart.php — set tax (defaults to 21, an EU VAT assumption), format, database.table, and destroy_on_logout before writing any cart code. Changing tax after items exist produces totals that don't reconcile.src/Cart.php — the public API surface. Skim the method list once; it is small (add, update, remove, destroy, content, search, store, restore, instance, setGlobalTax).src/CartItem.php — the object your Blade views and checkout logic will actually touch (rowId, id, name, qty, price, weight, options, model).First use case: an add-to-cart endpoint plus a cart page
// app/Http/Controllers/CartController.php
public function add(Request $request, Product $product)
{
$request->validate(['qty' => 'required|integer|min:1|max:20']);
Cart::add([
'id' => $product->id,
'name' => $product->name,
'qty' => $request->integer('qty'),
'price' => $product->price,
'weight' => $product->weight ?? 0,
'options' => ['size' => $request->input('size')],
])->associate(Product::class);
return redirect()->route('cart.index');
}
public function index()
{
return view('cart.index', ['cart' => Cart::content()]);
}
@foreach ($cart as $rowId => $item)
<tr>
<td>{{ $item->name }} {{ $item->options->size }}</td>
<td>{{ $item->qty }}</td>
<td>{{ $item->price }}</td>
<td>{{ $item->subtotal }}</td>
</tr>
@endforeach
<p>Subtotal: {{ Cart::subtotal() }} / Total: {{ Cart::total() }}</p>
The non-suffixed money methods (subtotal(), total(), tax()) return strings formatted via config('cart.format'); the *Float() variants return raw floats. Use the float variants for any arithmetic.
Array-form add() with a model association. Prefer the array signature over the legacy positional one — it's order-independent and self-documenting. Chaining ->associate(Product::class) stores the class name on the item so $item->model lazily resolves the Eloquent record; this saves N+1 lookups when rendering a cart page but will re-query per item, so eager-load separately if you need relations.
Multi-instance carts. Cart::instance('wishlist') gives you a fully separate cart in the same session, which is the cleanest way to build a wishlist, a "save for later" list, or a comparison tray alongside the shopping cart:
Cart::instance('wishlist')->add([...]);
Cart::instance('wishlist')->content();
// Back to the default cart — the instance is sticky, so be explicit.
Cart::instance('cart')->content();
Update/remove via rowId, not product id. The cart row key is a hash of the product id and its options, so the same product in two sizes is two rows. Route-model-bind on the rowId or pass it through a form hidden field, then verify ownership/authorization yourself — the package has none.
Cart::update($rowId, ['qty' => $request->integer('qty')]);
Cart::remove($rowId);
Persisting carts across sessions/devices. Use the shoppingcart table (store/restore) rather than dumping Cart::content() into your own column:
// On login
Cart::restore(auth()->id());
// On logout
Cart::store(auth()->id());
Wire these into EventServiceProvider listeners for Illuminate\Auth\Events\Login and Logout, or into a small middleware. This is the pattern that makes guest → authenticated cart merging work, and it's the main reason to run the migration at all.
As of 4.2.0 the serialised cart is base64-encoded before it is written to the shoppingcart table, which fixes truncation of the payload on PostgreSQL. Treat the content column as an opaque blob owned by the package: don't read it, don't json_decode it, don't write to it from your own code.
Checkout as a re-validation step, not a cart read. Treat the cart as a UI cache, never as a source of truth. At checkout, loop Cart::content(), re-fetch each product from the database, re-check price and stock, then build the order inside a transaction and call Cart::destroy() last:
DB::transaction(function () use ($cart) {
$order = Order::create([...]);
foreach ($cart as $item) {
$product = Product::lockForUpdate()->findOrFail($item->id);
abort_if($product->stock < $item->qty, 422, 'Out of stock');
$order->items()->create([
'product_id' => $product->id,
'qty' => $item->qty,
'unit_price' => $product->price, // recalculated, not $item->price
]);
}
});
Cart::destroy();
Discounts and coupons live outside the package. There is no conditions/coupons engine here (that older Crinsane tutorial feature is gone). Keep a coupon service that reads Cart::subtotalFloat() and returns a discount amount, then apply it at order creation and in the totals partial. Do not mutate $item->price on cart items to fake a discount — it desynchronises the row from the product and breaks the rowId assumptions downstream.
Totals partial. Centralise money formatting so the cart page, mini-cart and checkout all agree:
// app/Support/Money.php
public static function format(float $value): string
{
return number_format($value, 2, config('cart.format.decimal_point'), config('cart.format.thousand_seperator'));
}
Testing. The cart is session-backed, so every test starts dirty. Reset in setUp() with Cart::destroy(), or namespace tests to Cart::instance('test'). Use $this->withSession([])/assertSessionHas to inspect state rather than asserting on formatted strings. If you assert on store()/restore() round-trips, assert that restore() yields the same content() — never assert on the raw column value, which is now base64 and will change again.
total() returns a string. This is the single most common bug: Cart::total() * 100 or passing it to a payment gateway silently produces "1,234.50". Use Cart::totalFloat(), subtotalFloat(), taxFloat(), weightFloat(). The same split applies to CartItem ($item->price string vs. $item->priceFloat).store() payload is now base64-encoded (fix for PostgreSQL truncation, PR #167). Any rows written by an earlier version are in the old raw-serialised format and restore() will not read them correctly. Before deploying 4.2.0, either flush the shoppingcart table (DB::table(config('cart.database.table'))->truncate()) or write a one-off migration that re-encodes each row. Silently keeping the old rows means returning users get an empty cart.store() appear to succeed while restore() returns nothing — or see truncation errors in the logs — you are on <4.2.0. Upgrade rather than widening the column; the encoding, not the length, was the problem.decimals, decimal_point, thousand_seperator); older 0.x-style configs used a positional array. If totals render as 0.00 or throw, open the published config/cart.php rather than copying a snippet from a blog post.add() increments when id + options match. Adding the same product with the same options merges quantities rather than creating a second row. If you need two distinct rows, give them a distinguishing option key (or an intentional one, like 'line' => $uuid).update() with 0. Set quantity to zero via remove($rowId) explicitly. Relying on update($rowId, 0) has changed behaviour across versions of this fork family and leaves zero-qty rows behind in some of them.instance() is sticky state on the singleton. After Cart::instance('wishlist'), every subsequent Cart:: call in that request hits the wishlist until you call Cart::instance('cart') again. In a controller that touches two carts, always qualify both.file, redis or database sessions for anything real.destroy_on_logout in config plus your own store()/restore() listeners will fight each other. Pick one: either the config flag or explicit event listeners.Cart::setGlobalTax(21) applies to the whole instance; per-item overrides go in the add() payload as a tax key. Mixed-rate baskets (food vs. standard VAT) need the per-item key on every add, not just the special ones — items added without it inherit the global rate.subtotalFloat() can drift by fractions of a cent over many rows. Round once at display and once at order creation; never compare two independently-computed totals with ===.qty is whatever you pass in — clamp and max: it in the FormRequest.composer.json, and if you're on PHP 8.2+ watch for dynamic-property deprecations on CartItem; a #[AllowDynamicProperties] shim or a small compat release is the fast fix. Budget for a fork or in-house replacement if you stay long-term — and note that any such fork must keep the base64 encoding, or you'll re-introduce the Postgres bug.Cart::content() is a Collection keyed by rowId. dump(Cart::content()->toArray()) gives you the full serialised state, including options, in one shot — faster than stepping through totals methods. When persistence misbehaves, dump content() after restore(), not the DB row: the column is now opaque base64 and will tell you nothing by eye.Cart in your own CartService and fire your own events there; that's also the right seam for coupon logic and re-pricing — and the right place to hide the store()/restore() upgrade concern if you ever swap the package out.How can I help you explore Laravel packages today?