overtrue/laravel-shopping-cart
A lightweight shopping cart package for Laravel with multiple cart instances, item quantities, price calculations, conditions, and flexible storage. Easy to integrate into simple e-commerce workflows and designed for clean, straightforward cart management.
Install and wire it up
composer require overtrue/laravel-shopping-cart
The package is archived (last release 2019), so pin the version explicitly and check the composer.json constraints before pulling it into a modern Laravel app. On Laravel 5.5+ the service provider is auto-discovered; on older versions register it manually:
// config/app.php
'providers' => [
Overtrue\LaravelShoppingCart\ShoppingCartServiceProvider::class,
],
'aliases' => [
'Cart' => Overtrue\LaravelShoppingCart\Facade::class,
],
Then publish the config (session key, formatting):
php artisan vendor:publish --provider="Overtrue\LaravelShoppingCart\ShoppingCartServiceProvider"
Two things matter more than anything else in this package:
Cart must be inside the web middleware group. An API route without StartSession will appear to "work" and then silently drop everything.config/shopping_cart.php. The session_key is the single value you'll inspect when debugging "where is my cart?" — session(config('shopping_cart.session_key')) is your window into the entire cart.First use case: add from a product page
// routes/web.php
Route::post('/cart', [CartController::class, 'store'])->name('cart.store');
Route::get('/cart', [CartController::class, 'index'])->name('cart.index');
public function store(Request $request)
{
$product = Product::findOrFail($request->input('product_id'));
Cart::add(
$product->id,
$product->name,
$product->price,
$request->integer('quantity', 1),
['size' => $request->input('size')] // attributes make the row unique
);
return redirect()->route('cart.index');
}
public function index()
{
return view('cart.index', [
'items' => Cart::content(),
'subtotal' => Cart::subtotal(),
'total' => Cart::total(),
]);
}
@foreach ($items as $rowId => $item)
<tr>
<td>{{ $item->name }} {{ $item->attributes['size'] ?? '' }}</td>
<td>{{ $item->qty }}</td>
<td>{{ $item->price }}</td>
<td>{{ number_format($item->price * $item->qty, 2) }}</td>
</tr>
@endforeach
<p>Total: {{ Cart::total() }}</p>
content() returns a collection keyed by row id; each item exposes id, name, price, qty, attributes, and conditions. Confirm the exact accessor names on your pinned version before building views around them.
Thin controller, row-id round trip. Every mutating action is addressed by the row id, not the product id, because the same product with different attributes is a different row. Put the row id in a hidden input and validate it:
public function update(Request $request, string $rowId)
{
abort_unless(Cart::get($rowId), 404);
$data = $request->validate(['quantity' => 'required|integer|min:1|max:99']);
Cart::update($rowId, $data['quantity']);
return back();
}
public function destroy(string $rowId)
{
Cart::remove($rowId);
return back();
}
Never trust the price from the request. Fetch the model, take $product->price server-side, and pass that to Cart::add(). The cart is a session blob — anyone can forge it through a cookie session driver.
Conditions are the right extension point for tax, shipping, and discounts. Implement the package's CartCondition contract and register conditions centrally rather than in each controller:
class VatCondition implements CartCondition
{
public function __construct(protected float $rate) {}
public function getName(): string { return 'vat'; }
public function getType(): string { return 'tax'; }
public function getTarget(): string { return 'total'; } // or 'subtotal'
public function getValue(): string|float { return $this->rate . '%'; }
public function getRules(): array { return []; }
public function apply(CartItem $item): float
{
return round($item->price * $item->qty * ($this->rate / 100), 2);
}
}
Register in a middleware or service provider so the cart's conditions stay in sync with current config:
Cart::removeCondition('vat');
Cart::condition(new VatCondition(config('shop.vat')));
Remove-then-add keeps it idempotent — otherwise repeated requests can stack duplicate conditions into the session.
Per-item conditions (e.g. a bulk discount) go in the fifth argument of add(); global conditions apply to the whole cart. Use target subtotal for discounts and total for tax/shipping, since total conditions are applied after subtotal ones.
Checkout into real tables. The cart is a staging area only; the moment money moves, write durable rows:
DB::transaction(function () use ($request) {
$order = Order::create([...]);
foreach (Cart::content() as $rowId => $item) {
$order->items()->create([
'product_id' => $item->id,
'qty' => $item->qty,
'unit_price' => $item->price,
'options' => $item->attributes,
]);
}
Cart::destroy();
});
Testing. Because state lives in the session, feature tests are straightforward but need a clean slate:
protected function setUp(): void
{
parent::setUp();
Cart::destroy();
}
Use $this->withSession([...]) to seed a pre-populated cart for update/remove tests instead of driving the whole add flow.
composer.json blocks your PHP/Laravel, don't fight it — vendor a fork or switch to a maintained alternative. Budget for an exit plan.file, redis, or database sessions.$request->session()->invalidate() (standard in Auth\LoginController) flushes the cart with everything else. If you want carts to survive logout, stash and restore the cart array around invalidation.Illuminate\Auth\Events\Login and merge manually (sum quantities on matching id+attributes) if you need it.format/number_format only masks the drift. If you need correctness, store integer minor units in the cart and divide at render time, and round explicitly inside each condition's apply().update($rowId, 0) is ambiguous. Some versions leave a zero-quantity row, some drop it. Be explicit: validate min:1 and call remove() when the user means "delete".CartItem and condition objects. Renaming properties on those classes, or changing the session_key, orphans every live cart on deploy. Bump the session key on breaking changes, or clear it in your deploy script.dd(Cart::content()) is the fastest look, followed by session(config('shopping_cart.session_key')) to see the raw payload. If the cart empties between requests, check middleware order and the session driver before suspecting the package.Cart::content() on every request rather than caching it in a service singleton.How can I help you explore Laravel packages today?