Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Cart Bundle Laravel Package

leaphly/cart-bundle

Leaphly Cart Bundle is a Symfony bundle that adds reusable shopping cart functionality to PHP applications. It includes cart and line-item models, a cart manager, Doctrine persistence, and extension points for custom products, pricing, and checkout integration.

View on GitHub
Deep Wiki
Context7

Getting Started

Do not composer require this. It is a Symfony 2 bundle: it registers via a Bundle class, a DependencyInjection/*Extension, compiler passes, and HttpKernel event hooks. A Laravel app has no bundle system, no compiler passes, and no second kernel you want to boot. Treat the repository as a read-only design reference and plan to reimplement the parts you need in Laravel idioms.

Minimal setup (30 minutes):

git clone https://github.com/leaphly/CartBundle /tmp/cartbundle
cd /tmp/cartbundle
cat composer.json          # read the PHP/Symfony/Doctrine constraints first — this usually closes the decision
ls Model/ Provider/ Event/ Resources/config/

Read composer.json before anything else. If it pins php: >=5.3,<5.5 and Symfony 2.x, the direct-adoption path is dead in a two-minute check and you should proceed straight to extraction.

Where to look first, in this order:

  1. Model/ — the cart aggregate and cart item. This is the payload you actually want: what fields a cart item carries (variant/product reference, quantity, unit price, computed total), and how the cart composes items.
  2. Provider/ — the persistence abstraction (CartProviderInterface-style contracts with Doctrine and in-memory implementations). This is the clearest lesson in the repo: cart logic never touches the ORM directly.
  3. Event/ or the dispatcher calls in the manager — the mutation lifecycle (item added, quantity changed, item removed, cart merged) is where the domain rules live.
  4. Resources/config/services.yml + compiler passes — only to understand how pieces are wired; you will not reuse it, but it tells you which classes are intended as extension points.
  5. Tests/ — in an abandoned package, tests are the specification. They document behaviour the README forgot.

First use case to extract: a single vertical slice — carts + cart_items tables, an AddItemToCart action, and a CartCalculator that produces line totals, discounts, tax, and a grand total. Ship that with tests, then extend. Do not attempt to port the HTTP layer (FOSRestBundle/JMSSerializer) — Laravel API Resources and Form Requests replace it in an afternoon.

Implementation Patterns

Aggregate + application service, not fat models. Keep the Eloquent models dumb (attributes, relations, scopes) and put mutations in actions so you can dispatch events and wrap transitions in transactions:

final class AddItemToCart
{
    public function __construct(private CartCalculator $calculator) {}

    public function handle(Cart $cart, string $variantKey, int $qty, int $unitPriceMinor): Cart
    {
        $item = $cart->items()->firstOrNew(['variant_key' => $variantKey]);
        $item->quantity = ($item->quantity ?? 0) + $qty;
        $item->unit_price = $unitPriceMinor;   // integer minor units, always
        $item->save();

        $this->calculator->recalculate($cart);  // persist totals on write

        event(new CartItemAdded($cart->fresh('items'), $item));
        return $cart;
    }
}

Item-level events are the real takeaway. The bundle's event model distinguishes cart-level from item-level changes. Mirror the granularity — CartItemAdded, CartItemQuantityChanged, CartItemRemoved, CartMerged, CartStateChanged — so promotions, analytics, and inventory-listeners subscribe without touching the cart code. Use DB::afterCommit() in the listener wrappers so events never fire for rolled-back writes.

Price resolution as a pipeline, with stored adjustments. The bundle keeps pricing policy out of the cart model and resolves prices through pluggable resolvers. Copy that shape:

interface PriceModifier {
    public function apply(CartTotals $totals, Cart $cart): CartTotals;
}

// app/Pricing: LineSubtotal -> PromotionModifier -> TaxModifier -> ShippingModifier

Persist each modifier's output as a cart_adjustments row (cart_id, source, type credit/debit, amount, label, metadata). Line total = item price × qty; grand total = sum of adjustments. This gives you explainable totals ("−$5 promo SAVE5", "+$4.20 tax") for free and makes checkout reconciliation a SUM() instead of a rebuild.

Provider abstraction buys you testability. Define CartRepository and bind Eloquent in production and an in-memory implementation in tests, exactly as the bundle's provider layer does. It also makes the "cart in Redis, persisted on checkout intent" scaling path a swap of one binding.

State machine instead of a boolean column. The bundle models cart lifecycle (cart → wishlist → ordered) with guarded transitions. In Laravel use spatie/laravel-model-states or a cart_transitions table, and guard the conversion endpoint with a DB transaction, a UNIQUE constraint on order_id, and an idempotency key from the client. This is the single highest-value concept in the package.

Guest-to-authenticated merge as an explicit action. On login, merge by variant key (sum quantities, take the max unit price or re-resolve, re-run the calculator, dispatch CartMerged), then soft-delete the guest cart. Write a test for the case where the same variant exists in both carts — that is the bug nobody catches until Christmas.

Replace the Symfony surface with Laravel equivalents. FOSRestBundle routes → Route::apiResource, JMS serialization → API Resources + spatie/laravel-data, Twig cart helpers → Blade components (<x-cart.totals :cart="$cart" />, <x-cart.line :item="$item" />) that read from the persisted totals rather than recomputing.

Test the calculator in isolation with golden files. Feed a fixture corpus of carts (empty, single item, quantity change, stacked discounts, unavailable product) through the pipeline and assert exact totals. This is your regression net when you later add tax or multi-currency.

Gotchas and Tips

Composer will fight you. doctrine/orm, symfony/*, friendsofsymfony/rest-bundle, and jms/serializer constraints from the Symfony 2 era conflict with anything modern. Run composer why-not in a throwaway branch if someone insists on trying; give it two hours, not two days.

Code is PHP 5-era and will not paste cleanly. Expect array() syntax, no scalar type hints, PSR-0-ish naming, and Doctrine ArrayCollection semantics (add() returns a boolean and mutates in place — Eloquent Collections do not behave the same). Retype every property and convert to [], strict types, and readonly where appropriate; the logic survives, the syntax does not.

Never use floats for money. The bundle predates the integer-minor-units consensus. Use integer cents (or brick/money) from day one; retrofitting after totals are in the database is a painful migration.

Recalculate on write, not on read. The tempting port is a getTotalAttribute() accessor that recomputes. That produces drift between the cart page, the API, and checkout, and hides N+1s. Persist totals in the write path and add an artisan command (cart:recalculate --stale) for repair.

Event ordering matters. If you dispatch CartItemRemoved before the totals are persisted, listeners read stale data. Persist → recalculate → dispatch, and keep domain events out of raw saving/deleting model hooks.

Deleted products break carts. Use soft deletes on variants and have the price resolver return an "unavailable" state on the line item rather than throwing. A cart that 500s because a product was unpublished is a lost sale.

Idempotency on state transitions. Double-submitted checkout buttons convert a cart twice. Unique constraint on (cart_id, transition) or an Idempotency-Key header, plus a row lock (lockForUpdate) inside the transaction.

Concurrency on merge. Two tabs adding items simultaneously can duplicate a line. Optimistic locking via an updated_at/version column, or upsert on (cart_id, variant_key) with a quantity increment.

Don't run two cart systems in parallel. A shadow-mode comparison is fine for a release cycle; two systems of record for one session is a defect generator.

Verify before copying code: confirm the license (likely MIT, but read LICENSE), and confirm there is no maintained fork that has modernized it — if one exists, evaluate that instead of the original.

Extension points worth stealing verbatim (as interfaces): the price resolver contract, the provider/repository contract, and the event name taxonomy. Everything else — the bundle class, DI extension, compiler passes, Twig extensions, REST controllers — is Symfony-specific plumbing you should discard.

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
store-doc/laravel-storage-driver
plus-it/laravel-translation-manager
diephp/perhaps
diephp/telegraph
osmaviation/spreadsheet
microsoft/fluentui-system-icons
mmccook/php-json-canonicalization-scheme
middlewares/firewall
qubiqx/montapacking
nyoncode/laravel-package-console
pnscripts/filament-broadquill-lite
pnscripts/laravel-sentfold-lite
vleap/mx-sdk-laravel-internal
twidpay/utils
htetmyathlaing/scout-elasticsearch-driver
artisan-toolbox/front-awesome
ls/client-assistant
users-au/laravel-client
cgv/localized-resource
noith/settings