jord-jd/laravel-extendable-basket
An extendable shopping basket for Laravel. Offers a flexible cart with swappable storage, pluggable item handlers, and customizable pricing and discount rules, letting you tailor basket behaviour to your app without rewriting core logic.
1. Install and pin expectations
composer require <vendor>/<package>:^6.1
php artisan vendor:publish --provider="<Vendor>\Basket\BasketServiceProvider"
php artisan migrate
v6.1.0 is the release that makes this package viable on modern stacks: it supports Laravel 5.5 through 13 and drops the runtime floor to PHP 7.1. Before you write any code, confirm your app's actual PHP version — if you're on 8.x everything works, but if you maintain a legacy 7.1/7.2 service you are no longer forced to upgrade PHP to use the cart.
2. Look here first
add() contract.Basket model and BasketItem model, plus any config keys for model swapping.Relation::morphMap() in AppServiceProvider — relevant now that the package persists morph aliases.3. First use case: guest cart add
use App\Models\Product;
$product = Product::findOrFail($id);
$item = Basket::add($product, 2); // BasketItem returned (new in 6.1.0)
$item->quantity; // 2
The one behavior you should internalize immediately: you no longer need to save the basket before adding an item. A manually constructed basket is persisted automatically on the first add(). For a session-scoped guest cart that means you can build the basket lazily at the edge of your controller and let the first add commit it.
$basket = new Basket(['user_id' => auth()->id()]); // not yet in DB
$basket->add($product, 1); // now persisted automatically
Trust the return value of add()
add() now returns the created or quantity-merged BasketItem, so stop re-querying to get the row or its quantity:
$item = Basket::add($product, $request->integer('quantity'));
return response()->json([
'id' => $item->id,
'quantity' => $item->quantity, // already merged if the line existed
]);
This makes "add to cart" JSON endpoints and Livewire/Alpine interactions one-liners instead of add-then-fetch.
Validate persistence before you add
The package now rejects unsaved basketable models with a clear exception. Keep that guard at the boundary of your form request rather than scattering try/catch:
$product = Product::find($request->product_id); // null if not persisted
abort_unless($product, 404);
Basket::add($product, $qty);
For bulk imports/seeders, wrap the loop in a transaction so a single bad model doesn't leave a half-built basket.
Persist early, reconcile late
Two viable workflows now:
add() persist (6.1.0 behavior). Best for guest carts and single-page checkout.Whichever you pick, make the "merge on login" path idempotent — after the merge, add() will quantity-merge rather than duplicate, which is exactly what you want.
Commit morph aliases before you have data
Because the package stores Laravel morph-map aliases when available, decide your alias table up front and keep it stable:
// AppServiceProvider::boot()
Relation::enforceMorphMap([
'product' => \App\Models\Product::class,
'variant' => \App\Models\ProductVariant::class,
]);
Treat aliases as a schema. Renaming product to catalog_product later means existing basketable_type rows no longer resolve.
Order placement: snapshot, then clear()
DB::transaction(function () use ($basket) {
$order = Order::createFromBasket($basket); // reads items + prices here
$basket->clear(); // single DELETE
return $order;
});
clear() removes every item in one query, which is the right default for "place order" and "logout". Because it's a mass delete, BasketItem model events/observers will not fire — if you rely on a deleted observer for stock release or audit logs, iterate and delete individually instead, or hook the order transaction directly.
Render basket totals defensively
Item pricing is resolved on read, and 6.1.0 introduces a clear error when an orphaned item's price is requested. In views, tolerate it rather than 500ing the cart page:
@foreach ($basket->items as $item)
@continue(! $item->basketable) {{-- orphaned polymorphic row --}}
...
@endforeach
Or catch the specific exception and drop the line, logging the orphan for cleanup.
The add() return type is a real API change. Code that ignored the return value is unaffected, but anything written as if ($basket->add($p)) or if ($basket->add($p) === true) will now be truthy on an object — which usually still "works" but silently hides failures. Grep your codebase for ->add( and check comparisons.
Auto-persist changes test expectations. Tests that constructed a basket and asserted $basket->exists === false, or counted Basket rows before an explicit save, will now fail. Add RefreshDatabase assertions around the first add() to lock the new behavior in deliberately.
Unsaved basketable exception is new. If you have a seeder, factory, or admin import that adds freshly make()-ed models, it will now throw instead of silently inserting a broken polymorphic reference. Switch those to create() or save() first.
Morph alias migration is the sneaky upgrade hazard. Rows written before you adopted a morph map contain fully-qualified class names; rows written after contain aliases. Matching on add (and orphan detection) compares stored strings, so a mixed table can produce duplicate lines that look like merge failures. If you already have cart data, backfill basketable_type in one pass before enabling aliases:
DB::table('basket_items')->where('basketable_type', Product::class)
->update(['basketable_type' => 'product']);
Orphaned rows are skipped, not removed. 6.1.0 makes the package resilient to deleted basketables (skipped when matching additions, clear error when a price is requested), but it does not clean them up. Schedule a small pruning job that deletes basket_items whose basketable is null, otherwise carts accumulate dead weight and your "items count" badge drifts from reality.
Re-publish the migration if you installed from an earlier tag. "Fix the cross-version test migration and Artisan command setup" means migration/command scaffolding changed. Diff the published stub against what's in your repo; a stale migration is a classic source of "works on SQLite, fails on MySQL" column-type mismatches.
PHP 7.1 floor constrains contributions and copy-pasted examples. Since the package must run on 7.1, don't paste Laravel 11-era syntax (arrow functions, typed properties, constructor promotion) into overrides you intend to upstream. It also means the package itself avoids newer constructs — if you're debugging internals, don't expect modern syntax there.
CI matrix tells you what's actually supported. v6.1.0 replaces Travis/Coveralls with a GitHub Actions matrix spanning Laravel 5.5–13. Use it as the source of truth for compatibility claims, and if you hit an issue on an exotic combination (e.g. Laravel 5.6 + PHP 8), check whether that cell exists in the matrix before filing a bug.
Extension points worth knowing. Prefer model swapping in config over subclassing internals; register the alias map in a service provider rather than per-call; and when you need side effects on removal, prefer explicit domain code (stock release at order placement) over relying on clear()'s mass-delete path.
Debugging checklist for "item didn't merge". 1) Is basketable_type identical between the existing row and the incoming model (alias vs FQCN)? 2) Is the model persisted? 3) Are the two adds hitting the same basket (session/user scope)? 4) Log the raw basketable_type value — it answers the first three questions most of the time.
How can I help you explore Laravel packages today?