sylius/cart-bundle
Sylius CartBundle provides flexible shopping cart functionality for Symfony and Sylius applications, including cart and cart item models, add/update/remove operations, totals calculation, and integration with ecommerce workflows.
Understand the two realities before you start: this is a Symfony bundle, not a Laravel package, and it is archived and deprecated in favor of Sylius/Sylius (where its code lives on as Sylius\Component\Order). Your realistic "first use case" is therefore one of:
sylius/cart-bundle and need to make a safe change or plan a migration.Do not composer require this into a modern Laravel project β the Symfony/PHP constraints will not resolve cleanly and there is no maintenance path.
Where to look first (in the repo):
| Path | Why it matters |
|---|---|
Model/Cart.php, Model/CartItem.php |
The domain. Note the cart/line split β copy this boundary. |
Provider/CartProvider.php |
Session-backed cart identity (getCart, setCart, abandonCart). |
Resolver/ItemResolver.php |
Turns a raw request payload into a normalized cart line. |
Resources/config/doctrine/*.orm.xml |
Doctrine XML mappings β there are no annotations or attributes. |
CartEvents.php |
The extension seams (add/remove/update). |
Processor/* |
The totals pipeline β how subtotal/tax/total are computed. |
Controller/CartItemController.php |
Add/remove/clear endpoints. |
Minimal wiring in a legacy Symfony app:
// app/AppKernel.php
$bundles = [ /* ... */ new Sylius\Bundle\CartBundle\SyliusCartBundle() ];
# config.yml
sylius_cart:
driver: doctrine/orm
classes:
cart: AppBundle\Entity\Cart
cart_item: AppBundle\Entity\CartItem
Then implement ItemResolverInterface for your product entity, register it as a service, and expose the bundle's routes.
First concrete use case β the "add to cart" round trip. Trace it end to end: CartItemController::addAction β ItemResolver normalizes the payload β CartProvider supplies the current cart β an add operation appends the line β processors recalculate totals β redirect. If you can narrate that flow from the source, you understand the bundle's entire contribution. Everything else is variations on remove/update/clear.
The Laravel equivalent you should actually build β start from the schema, not a package:
Schema::create('carts', function (Blueprint $t) {
$t->id();
$t->foreignId('user_id')->nullable()->index();
$t->string('session_id')->nullable()->index();
$t->string('currency', 3);
$t->timestamps();
});
Schema::create('cart_items', function (Blueprint $t) {
$t->id();
$t->foreignId('cart_id')->constrained()->cascadeOnDelete();
$t->morphs('buyable'); // product, variant, subscriptionβ¦
$t->unsignedInteger('quantity');
$t->unsignedInteger('unit_price'); // integer minor units β never float
$t->json('options')->nullable();
$t->timestamps();
$t->unique(['cart_id', 'buyable_type', 'buyable_id']);
});
First deliverable: a CartManager bound as a scoped singleton that resolves the cart from session or auth and exposes add(), remove(), clear(). That is roughly 90% of what the bundle did.
1. The Provider pattern β never let controllers touch the session key. The bundle's CartProvider existed so that "which cart am I operating on?" was answered in exactly one place. Copy that discipline:
final class CartManager
{
public function __construct(private CartRepository $carts) {}
public function current(): Cart
{
$key = auth()->check()
? ['user_id' => auth()->id()]
: ['session_id' => session()->getId()];
return $this->carts->firstOrCreate($key, ['currency' => config('shop.currency')]);
}
}
2. The ItemResolver pattern β one funnel from user input to cart line. The resolver took a raw payload and produced a normalized [product, quantity, price, options]. Keep that funnel and make it return a DTO or throw:
final class CartItemResolver
{
public function resolve(AddToCartData $data): ResolvedItem
{
$variant = ProductVariant::findOrFail($data->variantId);
abort_unless($variant->isPurchasable(), 422, 'Not purchasable');
return new ResolvedItem(
buyable: $variant,
quantity: $data->quantity,
unitPrice: $variant->price, // already minor units
);
}
}
3. The processor pipeline β totals as a deterministic function. The bundle recalculated subtotal/tax/total via configurable processors rather than inline math in a controller. Mirror this with Laravel's Pipeline or a set of small invokable classes, so add, update, and merge all recompute identically:
$total = app(Pipeline::class)
->send($cart)
->through([
ApplyLineSubtotals::class,
ApplyAdjustments::class,
ApplyTaxes::class,
ApplyGrandTotal::class,
])->thenReturn();
Trigger recalculation on every mutation β never trust a stored total after a quantity change.
4. Operation objects for mutations. Rather than fat controller methods, model each mutation as a small class (AddToCart, UpdateQuantity, RemoveItem, ClearCart) that resolves the cart, mutates lines, recalculates, and dispatches an event. This mirrors the bundle's operation-plus-event split and makes each op independently testable.
5. Guest-to-user merge on login. The bundle's session-backed provider implies cart identity changes at authentication. Handle the merge explicitly: on Login, find the session cart, find or create the user cart, add session lines into it (summing quantities for identical buyable), then abandonCart() the session one. Do this inside a transaction and guard against races (two tabs, double-submit) with a DB lock.
6. Keep the cart/order boundary. The single most valuable idea in the bundle: a cart is not an order. Cart is mutable and session-scoped; order is an immutable snapshot created by converting the cart. If you collapse them into one table you will regret it at your first "customer changed their address after checkout" bug. Model conversion as an explicit transaction that copies lines and freezes prices.
7. Events as extension seams. The bundle exposed CartEvents so other code could react without subclassing. In Laravel, dispatch CartItemAdded, CartUpdated, CartCleared after the mutation commits, and let listeners handle cross-cutting concerns (analytics, promo re-evaluation, inventory holds).
Archived means you own it. No security backports, no PHP/Laravel compatibility work, no escalation path. If this is in a production tree, it is a remediation item with a scheduled exit, not a dependency to extend. Inventory every usage before planning a feature on top of it.
Doctrine XML mappings, not attributes. If you go looking for a PHP entity with @ORM annotations you'll waste an hour β the models are mapped in Resources/config/doctrine/*.orm.xml. When porting to Eloquent, translate column-by-column; don't assume the model class defines the schema.
Quantity is an integer, units are the mature design. Pre-1.0 the line held a plain quantity. The successor design splits a line into per-unit rows, which is what makes serial numbers, per-unit fulfillment, and partial refunds tractable. If your domain needs any of those, adopt the units model from day one β retrofitting it later is a painful migration.
Money as integers, always. Every price path should be integer minor units. The most common porting bug is a float creeping in at the resolver boundary ($variant->price in cents, a shipping or tax processor computing in dollars). Assert this with tests.
Session carts don't scale horizontally. The provider pattern is session-bound, which breaks behind a load balancer without sticky sessions or shared session storage. Prefer database or Redis persistence keyed by session ID, and be ready for cart-merge races on login β the silent-data-loss failure mode to watch for.
Recalculation is not free. Running the processor pipeline on every mutation is correct but O(lines Γ adjustments). Cache the pipeline result per request and only recompute the lines that changed if carts grow large.
Debugging a legacy install: if totals are wrong, isolate the processor β dump cart lines before the pipeline and after each stage. If "add to cart" silently fails, check the ItemResolver first; a resolver returning null or throwing a caught exception is the usual culprit.
Extension points if you must modify it in place: implement ItemResolverInterface for your product type, register a custom processor in the totals pipeline via config, and subscribe to CartEvents rather than overriding controllers. Subclassing Cart/CartItem is supported through the classes: config β but each override is a future migration liability.
Version constraints are the real integration cost. Symfony 2.x/3.x-era components will collide with Laravel 10/11's shared Symfony packages. A timeboxed spike to confirm the conflict is worthwhile before any "just try it" plan; expect composer resolution to fail rather than degrade gracefully.
Migration path out: move to Sylius/Sylius if you're staying on the Sylius platform (and consider exposing the shop API rather than embedding it), otherwise port the concepts β provider, resolver, operation objects, processor pipeline, cart/order split β into native Laravel code. The domain is small enough that a clean reimplementation beats a bridge in almost every case.
How can I help you explore Laravel packages today?