offline-agency / laravel-cart
Laravel shopping cart with fiscal support
Fund package maintenance!
Requires
- php: ^8.4
- illuminate/database: ^10.0|^11.0|^12.0
- illuminate/events: ^10.0|^11.0|^12.0
- illuminate/session: ^10.0|^11.0|^12.0
- illuminate/support: ^10.0|^11.0|^12.0
Requires (Dev)
- larastan/larastan: ^3.8
- laravel/pint: ^1.26
- mockery/mockery: ^1.6
- orchestra/testbench: ^10.0
- pestphp/pest: ^3.0
- pestphp/pest-plugin-laravel: ^3.0
- php-coveralls/php-coveralls: ^2.7
- dev-main
- 3.1.5
- 3.1.4
- 3.1.3
- 3.1.2
- 3.1.1
- 3.1.0
- 3.0.1
- 3.0.0
- 2.0.2
- 2.0.1
- 2.0.0
- 1.0.4
- 1.0.3
- 1.0.2
- 1.0.1
- 1.0.0
- 0.1.1
- 0.1.0
- dev-develop
- dev-upgrade-to-laravel-12-with-cursor
- dev-fix-cart-applied-coupon
- dev-feat-add-sync-method
- dev-feat-add-global-coupons
- dev-analysis-VreOe6
- dev-feat-add-coupons-implementation
- dev-analysis-pearP2
This package is auto-updated.
Last update: 2026-03-11 22:37:47 UTC
README
A Laravel shopping cart with fiscal support. Handles VAT-inclusive pricing, Italian fiscal codes, per-item and cart-wide coupons, database persistence, and multiple cart instances.
Requirements
PHP 8.2 or higher is required.
| Laravel | PHP | Package |
|---|---|---|
| 10.x | ^8.2 | ^3.x |
| 11.x | ^8.2 | ^3.x |
| 12.x | ^8.2 | ^4.x |
Installation
Install the package via Composer:
composer require offline-agency/laravel-cart
Publish the configuration file:
php artisan vendor:publish --tag=cart-config
Publish and run the migrations (required only if you use database persistence):
php artisan vendor:publish --tag=cart-migrations php artisan migrate
Service provider auto-discovery registers the package automatically. No manual registration is needed.
Quick Start
Add an item in a controller and display it in a Blade view:
// In your controller use OfflineAgency\LaravelCart\Facades\Cart; $item = Cart::add( id: 42, name: 'Blue T-Shirt', subtitle: 'Size M', qty: 2, price: 19.67, // price without VAT totalPrice: 24.00, // price with VAT included vat: 4.33, // VAT amount per unit ); return view('cart.show');
{{-- resources/views/cart/show.blade.php --}} @foreach (Cart::content() as $item) <tr> <td>{{ $item->name }}</td> <td>{{ $item->subtitle }}</td> <td>{{ $item->qty }}</td> <td>{{ $item->totalPrice }}</td> </tr> @endforeach <p>Total (with VAT): {{ Cart::total() }}</p> <p>Subtotal (ex-VAT): {{ Cart::subtotal() }}</p> <p>Total VAT: {{ Cart::vat() }}</p>
Configuration
Publish the config file with php artisan vendor:publish --tag=cart-config before changing these values.
| Key | Type | Default | Description |
|---|---|---|---|
database.connection |
string|null |
null |
Database connection name. null uses the application default. |
database.table |
string |
'cart' |
Table name for stored carts. |
destroy_on_logout |
bool |
false |
When true, all cart instances are destroyed when the user logs out. |
format.decimals |
int |
2 |
Number of decimal places for formatted output. |
format.decimal_point |
string |
'.' |
Decimal point character. |
format.thousand_separator |
string |
',' |
Thousand separator character. |
global_coupons_enabled |
bool |
true |
Enable the cart-wide coupon system (addGlobalCoupon / globalCouponDiscount). |
coupon_class |
string |
CartCoupon::class |
Class used to represent coupon objects. |
use_legacy_events |
bool |
true |
When true, string events (cart.added, etc.) are dispatched alongside typed event objects. Set to false to dispatch only typed events. |
rounding_mode |
int |
PHP_ROUND_HALF_UP |
Rounding mode used by vatBreakdown(). Any PHP_ROUND_* constant is accepted. |
CartItem Reference
Every Cart::add() call returns a CartItem instance. The following properties are available:
| Property | Type | Description |
|---|---|---|
rowId |
string |
MD5 hash derived from $id + serialized $options. Two items with the same id but different options produce different rowId values. |
id |
int|string |
The product identifier passed to add(). |
name |
string |
Product name. |
subtitle |
string |
Product subtitle or short description. |
qty |
int |
Quantity. |
price |
float |
Unit price excluding VAT, after any coupons. |
totalPrice |
float |
Unit price including VAT, after any coupons. |
vat |
float |
VAT amount per unit, after any coupons. |
vatRate |
float |
VAT rate as a percentage (calculated: 100 × vat / price). |
vatLabel |
string |
'Iva Inclusa' when VAT > 0, otherwise 'Esente Iva'. |
originalPrice |
float |
Unit price excluding VAT before any coupons. |
originalTotalPrice |
float |
Unit price including VAT before any coupons. |
originalVat |
float |
VAT per unit before any coupons. |
discountValue |
float |
Total discount amount applied to this item across all coupons. |
vatFcCode |
string |
VAT nature code for fiscal receipts (e.g. Italian N2, N4). |
productFcCode |
string |
Product fiscal code for receipts. |
urlImg |
string |
Product image URL. |
options |
CartItemOptions |
Arrayable collection of custom options. Access as $item->options->size. |
associatedModel |
string|null |
Fully-qualified class name of the associated Eloquent model. |
model |
Model|null |
The associated Eloquent model instance (loaded via find($id) on access). |
appliedCoupons |
array |
Keyed array of coupons applied to this item. |
priceTax |
float |
price + tax (computed via __get). |
subtotal |
float |
qty × price (computed via __get). |
total |
float |
qty × priceTax (computed via __get). |
tax |
float |
price × (taxRate / 100) (computed via __get). |
taxTotal |
float |
tax × qty (computed via __get). |
How rowId works: The rowId is computed as md5($id . serialize(ksort($options))). Two cart items with id = 5 but options = ['color' => 'red'] and options = ['color' => 'blue'] produce different rowId values and appear as separate rows. Use options intentionally to force separation of otherwise identical products.
Usage
Cart::add()
Cart::add( mixed $id, mixed $name = null, ?string $subtitle = null, ?int $qty = null, ?float $price = null, ?float $totalPrice = null, ?float $vat = null, ?string $vatFcCode = '', ?string $productFcCode = '', ?string $urlImg = '', array $options = [] ): array|CartItem
Adds one or more items to the cart. Returns a CartItem when a single item is added, or an array of CartItem when an array of items is passed.
Adding a single item by attributes:
$item = Cart::add( id: 1, name: 'White Shirt', subtitle: 'Size L', qty: 1, price: 19.67, totalPrice: 24.00, vat: 4.33, vatFcCode: '', productFcCode: '', urlImg: 'https://example.com/shirt.jpg', options: ['color' => 'white', 'size' => 'L'] );
Adding a Buyable instance (when $id implements Buyable, $name acts as the quantity):
$product = Product::find(1); // implements Buyable $item = Cart::add($product, 2); // 2 = qty
Adding multiple items at once:
$items = Cart::add([ ['id' => 1, 'name' => 'Shirt', 'subtitle' => '', 'qty' => 1, 'price' => 19.67, 'totalPrice' => 24.00, 'vat' => 4.33], ['id' => 2, 'name' => 'Jeans', 'subtitle' => '', 'qty' => 2, 'price' => 49.18, 'totalPrice' => 60.00, 'vat' => 10.82], ]); // $items is an array of CartItem
If an item with the same rowId already exists, the quantities are summed.
Cart::update()
Cart::update(string $rowId, mixed $qty): ?CartItem
Updates the cart item identified by $rowId. $qty accepts an integer, an associative array of attributes, or a Buyable instance. Returns null when the item is removed (qty ≤ 0).
// Update quantity Cart::update($item->rowId, 3); // Update multiple attributes Cart::update($item->rowId, ['qty' => 3, 'price' => 15.00]); // Setting qty to 0 or negative removes the item Cart::update($item->rowId, 0); // returns null, item removed
Cart::remove()
Cart::remove(string $rowId): void
Removes the item with the given rowId from the cart. All per-item coupons are silently detached before removal. Fires cart.removed.
Cart::remove($item->rowId);
Throws: InvalidRowIDException if $rowId does not exist.
Cart::get()
Cart::get(string $rowId): CartItem
Returns the CartItem with the given rowId.
$item = Cart::get('d8e4a45c...'); echo $item->name;
Throws: InvalidRowIDException if $rowId does not exist.
Cart::content()
Cart::content(): Collection<string, CartItem>
Returns all items in the current cart instance as a Collection keyed by rowId.
$items = Cart::content(); foreach ($items as $rowId => $item) { echo "{$item->name}: {$item->qty} × {$item->totalPrice}"; }
Cart::destroy()
Cart::destroy(): void
Removes all items, clears global coupons, and removes cart options from the session.
// After successful checkout Cart::destroy(); // For a specific instance Cart::instance('wishlist')->destroy();
Cart::total()
Cart::total( ?int $decimals = null, ?string $decimalPoint = null, ?string $thousandSeparator = null ): float
Returns the cart total including VAT, after all item-level coupons. Falls back to config('cart.format.*') values when parameters are null. The result is never negative (returns 0 if coupons exceed the total).
$total = Cart::total(); // e.g. 88.00 $formatted = Cart::total(2, '.', ','); // uses explicit format
The magic property $cart->total is available when the Cart object is resolved via dependency injection (not the Facade).
Cart::subtotal()
Cart::subtotal( ?int $decimals = null, ?string $decimalPoint = null, ?string $thousandSeparator = null ): float
Returns the sum of all item price values (excluding VAT). Discount cart items are excluded from the subtotal calculation.
$subtotal = Cart::subtotal(); // e.g. 72.13
Cart::vat()
Cart::vat( ?int $decimals = null, ?string $decimalPoint = null, ?string $thousandSeparator = null ): float
Returns the total VAT amount across all items in the cart.
$totalVat = Cart::vat(); // e.g. 15.87
Cart::totalVatLabel() returns 'Iva Inclusa' when any VAT is present, or 'Esente Iva' when total VAT is zero.
Cart::count()
Cart::count(): int|float
Returns the sum of all item quantities (not the number of distinct rows).
// Cart has 2 × Shirt and 3 × Jeans Cart::count(); // 5
Cart::search()
Cart::search(Closure $search): Collection<string, CartItem>
Filters cart content using a closure. Returns a Collection of matching CartItem instances.
// Find items by product id $found = Cart::search(fn (CartItem $item) => $item->id === 42); // Find items with a specific option value $redItems = Cart::search(fn (CartItem $item) => $item->options->color === 'red'); // Find by name (case-insensitive) $shirts = Cart::search(fn (CartItem $item) => str_contains(strtolower($item->name), 'shirt'));
Utility Methods
Cart::isEmpty(): bool Cart::isNotEmpty(): bool Cart::uniqueCount(): int // number of distinct rows (not sum of qty) Cart::first(?Closure $callback = null): ?CartItem // first item, or first matching a closure Cart::where(string $key, mixed $value): Collection<string, CartItem>
if (Cart::isEmpty()) { return redirect()->route('shop'); } $itemCount = Cart::uniqueCount(); // 2 rows even if total qty is 5 $first = Cart::first(); $shirt = Cart::first(fn (CartItem $item) => $item->id === 42); $redItems = Cart::where('options.color', 'red');
Cart::addBatch()
Cart::addBatch(array $items): Collection<string, CartItem>
Adds multiple items from an array and returns the updated cart content.
Cart::addBatch([ ['id' => 1, 'name' => 'Shirt', 'subtitle' => '', 'qty' => 1, 'price' => 19.67, 'totalPrice' => 24.00, 'vat' => 4.33], ['id' => 2, 'name' => 'Jeans', 'subtitle' => '', 'qty' => 1, 'price' => 49.18, 'totalPrice' => 60.00, 'vat' => 10.82], ]);
Cart::sync()
Cart::sync(array $items): static
Synchronises the cart with the given items array. Items in the cart that are absent from $items are removed. Items in $items that are absent from the cart are added. Items present in both are updated to the quantity from $items.
Cart::sync([ ['id' => 1, 'name' => 'Shirt', 'subtitle' => '', 'qty' => 3, 'price' => 19.67, 'totalPrice' => 24.00, 'vat' => 4.33], // id 2 is absent → removed from cart if it was there ]);
Cart::associate()
Cart::associate(string $rowId, mixed $model): void
Associates the cart item with an Eloquent model. After calling this, $item->model returns the model instance via find($item->id).
Cart::associate($item->rowId, \App\Models\Product::class); $product = Cart::get($item->rowId)->model; // triggers Product::find($item->id)
Throws: UnknownModelException when a string class name is passed that does not exist.
Cart::numberFormat()
Cart::numberFormat( float|int $value, ?int $decimals, ?string $decimalPoint, ?string $thousandSeparator ): string
Formats a number using the cart's configured (or provided) format settings.
echo Cart::numberFormat(1234.5, 2, '.', ','); // "1,234.50"
Instances
The cart supports multiple named instances, each stored independently in the session.
Cart::instance(?string $instance = null): Cart Cart::currentInstance(): string
The default instance name is 'default'. Switch instances with Cart::instance(). The call is fluent, so you can chain operations.
// Work with a wishlist alongside the main cart Cart::instance('wishlist')->add(5, 'Gift Item', '', 1, 30.00, 36.60, 6.60); Cart::instance('shopping')->add(7, 'Daily Use', '', 2, 10.00, 12.20, 2.20); // Restore to default Cart::instance(); // back to 'default' echo Cart::instance('wishlist')->currentInstance(); // 'wishlist'
The Buyable Interface
Any class can be passed directly to Cart::add() by implementing OfflineAgency\LaravelCart\Contracts\Buyable:
namespace OfflineAgency\LaravelCart\Contracts; interface Buyable { public function getId(): int|string; public function setId(int|string $id): void; public function getName(): string; public function setName(string $name): void; public function getSubtitle(): string; public function setSubtitle(string $subtitle): void; public function getQty(): int; public function setQty(int $qty): void; public function getPrice(): float; public function setPrice(float $price): void; public function getTotalPrice(): float; public function setTotalPrice(float $totalPrice): void; public function getVat(): float; public function setVat(float $vat): void; public function getVatFcCode(): string; public function setVatFcCode(string $vatFcCode): void; public function getProductFcCode(): string; public function setProductFcCode(string $productFcCode): void; public function getUrlImg(): string; public function setUrlImg(mixed $urlImg): void; public function getOptions(): array; public function setOptions(array $options): void; }
Complete Product model example:
namespace App\Models; use Illuminate\Database\Eloquent\Model; use OfflineAgency\LaravelCart\Contracts\Buyable; class Product extends Model implements Buyable { protected $fillable = [ 'name', 'subtitle', 'price', 'total_price', 'vat', 'vat_fc_code', 'product_fc_code', 'url_img', ]; public function getId(): int|string { return $this->id; } public function setId(int|string $id): void { $this->id = $id; } public function getName(): string { return $this->name; } public function setName(string $name): void { $this->name = $name; } public function getSubtitle(): string { return $this->subtitle ?? ''; } public function setSubtitle(string $subtitle): void { $this->subtitle = $subtitle; } public function getQty(): int { return 1; } public function setQty(int $qty): void {} public function getPrice(): float { return (float) $this->price; } public function setPrice(float $price): void { $this->price = $price; } public function getTotalPrice(): float { return (float) $this->total_price; } public function setTotalPrice(float $totalPrice): void { $this->total_price = $totalPrice; } public function getVat(): float { return (float) $this->vat; } public function setVat(float $vat): void { $this->vat = $vat; } public function getVatFcCode(): string { return $this->vat_fc_code ?? ''; } public function setVatFcCode(string $vatFcCode): void { $this->vat_fc_code = $vatFcCode; } public function getProductFcCode(): string { return $this->product_fc_code ?? ''; } public function setProductFcCode(string $productFcCode): void { $this->product_fc_code = $productFcCode; } public function getUrlImg(): string { return $this->url_img ?? ''; } public function setUrlImg(mixed $urlImg): void { $this->url_img = $urlImg; } public function getOptions(): array { return []; } public function setOptions(array $options): void {} }
Auto-association: When Cart::add($product, $qty) receives a Buyable, the cart automatically calls associate() on the resulting CartItem. Accessing $item->model later returns a fresh Product::find($item->id) instance.
Using the CanBeBought trait: For models with standard property names (id, name, title, description, price), the CanBeBought trait provides default implementations of getId(), getName()/getSubtitle()/getDescription(), and getPrice():
use OfflineAgency\LaravelCart\CanBeBought; use OfflineAgency\LaravelCart\Contracts\Buyable; class SimpleProduct extends Model implements Buyable { use CanBeBought; // Only implement the remaining methods not covered by the trait public function getSubtitle(): string { return ''; } // ... etc. }
Model Association
Associate a cart item with an Eloquent model after it has been added:
$item = Cart::add(42, 'Shirt', '', 1, 19.67, 24.00, 4.33); Cart::associate($item->rowId, \App\Models\Product::class);
After association, accessing $item->model triggers Product::find($item->id) and returns the model. The association stores only the class name in the session; the model is not serialized.
$item = Cart::get($rowId); $product = $item->model; // App\Models\Product instance, or null if not found
Throws: UnknownModelException when the supplied string class name does not exist.
Coupons
The package supports two distinct coupon systems: item-level coupons that reduce the price of a specific cart item, and cart-level (global) coupons that are calculated against the cart total.
Item-Level Coupons
// Preferred non-deprecated alias (v4.1+) Cart::addItemCoupon( mixed $rowId, string $couponCode, string $couponType, // 'fixed' or 'percentage' float $couponValue ): void // Kept for backward compatibility — deprecated since 4.1 Cart::applyCoupon(mixed $rowId, string $couponCode, string $couponType, float $couponValue): void
Apply a coupon to a specific item. The rowId must be a valid cart item row.
// Add an item first to obtain the rowId $item = Cart::add(1, 'Shirt', '', 2, 19.67, 24.00, 4.33); // Apply a fixed €5 discount Cart::addItemCoupon($item->rowId, 'SAVE5', 'fixed', 5.00); // Or apply a 10% discount Cart::addItemCoupon($item->rowId, 'PROMO10', 'percentage', 10.0);
Discount calculation:
'fixed': deducts$couponValuefromtotalPrice, then back-calculatespriceandvat'percentage': deducts($couponValue / 100) × originalTotalPricefromtotalPrice, then back-calculatespriceandvat
Multiple coupons can be applied to the same item. Each subsequent coupon operates on the already-reduced totalPrice.
Removing item-level coupons:
// Remove one coupon from a specific item Cart::detachCoupon($item->rowId, 'SAVE5'); // Remove a coupon by code, searching all items Cart::removeCoupon('SAVE5'); // throws InvalidCouponHashException if not found // Remove all per-item coupons from the entire cart Cart::removeAllCoupons();
Querying item-level coupons:
// Check whether any per-item coupon exists Cart::hasCoupons(); // bool // Check for a specific coupon code Cart::hasCoupon('SAVE5'); // bool // Retrieve a specific coupon object $coupon = Cart::getCoupon('SAVE5'); // Get all per-item coupons as a raw array $raw = Cart::coupons(); // array<string, object> // Get all per-item coupons as CartCoupon instances $coupons = Cart::getCoupons(); // Collection<string, CartCoupon>
Cart-Level (Global) Coupons
Global coupons apply a discount to the cart total and are calculated separately from item prices. Two APIs exist: the new addCoupon() API (v4.1+, recommended) and the legacy addGlobalCoupon() API (still supported).
New API (v4.1+)
// Add a CartCoupon object (full validation: expiry, minCartAmount) Cart::addCoupon(string|CartCoupon|Couponable $coupon): static // Remove by hash or coupon code Cart::removeCartCoupon(string $hashOrCode): static // Query Cart::listCoupons(): Collection // all cart-level coupons Cart::hasCartCoupon(string $hashOrCode): bool Cart::discount(): float // total discount amount from all coupons Cart::syncCoupons(): array // re-validate; returns removed coupon codes
use Carbon\Carbon; use OfflineAgency\LaravelCart\CartCoupon; use OfflineAgency\LaravelCart\Exceptions\CouponAlreadyAppliedException; use OfflineAgency\LaravelCart\Exceptions\InvalidCouponException; $coupon = new CartCoupon( hash: 'promo-2025', code: 'SUMMER25', type: 'percentage', value: 25.0, isGlobal: true, expiresAt: Carbon::parse('2025-08-31'), minCartAmount: 50.0, ); try { Cart::addCoupon($coupon); // validates expiry and minCartAmount } catch (InvalidCouponException $e) { // coupon expired or cart total is below minCartAmount } catch (CouponAlreadyAppliedException $e) { // same hash already in the cart } Cart::discount(); // e.g. 25.00 (25% of 100.00) Cart::total(); // deducted automatically: 75.00 Cart::removeCartCoupon('SUMMER25'); // remove by code or hash
Cart::total() automatically deducts cart-level coupon discounts. You do not need to subtract manually.
Legacy API
Cart::addGlobalCoupon( string $couponHash, string $code, string $type, // 'percentage' | 'fixed' float|int $value ): static
// Add a 10% cart-wide coupon Cart::addGlobalCoupon('hash-abc', 'CART10', 'percentage', 10.0); // Add a fixed €20 discount Cart::addGlobalCoupon('hash-xyz', 'FLAT20', 'fixed', 20.0);
Global coupons persist in the session alongside cart items. Use $couponHash (any unique string) as the key to remove a specific coupon later.
Managing legacy global coupons:
// Remove one global coupon by hash Cart::removeGlobalCoupon('hash-abc'); // throws InvalidCouponHashException if not found // Get all global coupons $globals = Cart::getGlobalCoupons(); // Collection<string, CartCoupon> // Calculate the total discount from all global coupons $cartTotal = (string) Cart::total(); // e.g. '100.00' $discount = Cart::globalCouponDiscount($cartTotal); // e.g. '30.00' $finalTotal = (float) $cartTotal - (float) $discount;
How Discounts Are Calculated
Item-level ('fixed'): The fixed value is subtracted from totalPrice. price and vat are back-calculated from the new totalPrice using the item's vatRate. The item's discountValue accumulates each coupon's contribution.
Item-level ('percentage'): The discount is ($value / 100) × originalTotalPrice. The result is subtracted from the current totalPrice (not originalTotalPrice), so stacked percentage coupons compound.
Global coupons — ordering: globalCouponDiscount() sorts coupons so percentage coupons apply first, then fixed coupons, regardless of insertion order.
Global coupon cap: Fixed global coupons are capped at the remaining total. The discount never drives the total below zero.
Worked numeric example:
Cart::total() = 100.00
Global coupon 1: 10% percentage
discount = 100.00 × 10 / 100 = 10.00
remaining = 90.00
Global coupon 2: fixed 20.00
discount = min(20.00, 90.00) = 20.00
remaining = 70.00
Cart::globalCouponDiscount('100.00') → '30.00'
final total = 100.00 - 30.00 = 70.00
CartCoupon Reference
Both item-level and global coupons are represented as CartCoupon objects:
final readonly class CartCoupon implements Couponable, JsonSerializable { public function __construct( public string $hash, public string $code, public string $type, // 'fixed' | 'percentage' public float $value, public bool $isGlobal = false, public ?Carbon $expiresAt = null, // null = never expires public ?int $usageLimit = null, public ?float $minCartAmount = null, ) {} public function isPercentage(): bool; public function isFixed(): bool; public function couponType(): CouponType; // bridge to CouponType enum public function isExpired(): bool; // true when expiresAt is in the past public function isApplicableTo(float $cartTotal): bool; // checks minCartAmount public function toArray(): array; public function jsonSerialize(): array; }
Because CartCoupon is final readonly, all properties are immutable after construction.
Creating a coupon with constraints:
use Carbon\Carbon; use OfflineAgency\LaravelCart\CartCoupon; $coupon = new CartCoupon( hash: 'promo-2025', code: 'SUMMER25', type: 'percentage', value: 25.0, isGlobal: true, expiresAt: Carbon::parse('2025-08-31'), minCartAmount: 50.0, ); $coupon->isExpired(); // false (before expiry) $coupon->isApplicableTo(49.99); // false (below minCartAmount) $coupon->isApplicableTo(50.00); // true
Fiscal Support (VAT)
The package is designed for VAT-inclusive pricing as used in Italian fiscal receipts.
VAT is passed as an amount, not a rate. When adding an item with price = 19.67, totalPrice = 24.00, and vat = 4.33, the cart stores all three values and derives vatRate = 22% automatically.
// Adding a 22% VAT item Cart::add( id: 1, name: 'Product A', subtitle: '', qty: 1, price: 19.67, // ex-VAT unit price totalPrice: 24.00, // VAT-inclusive unit price vat: 4.33, // VAT amount vatFcCode: '', // VAT nature code (e.g. 'N4' for exempt) productFcCode: '', // product fiscal code );
Per-item fiscal properties:
| Property | Description |
|---|---|
vatRate |
Calculated: 100 × vat / price |
vatLabel |
'Iva Inclusa' when vat > 0, else 'Esente Iva' |
vatFcCode |
VAT nature code for the fiscal document |
productFcCode |
Product fiscal code |
Cart-level totals:
Cart::total(); // sum of all item totalPrice × qty, minus cart-level coupon discounts Cart::subtotal(); // sum of all item price × qty (ex-VAT) Cart::vat(); // sum of all item vat × qty Cart::totalVatLabel(); // 'Iva Inclusa' or 'Esente Iva'
VAT breakdown for fiscal receipts:
Cart::vatBreakdown(): Collection<int, array{rate: float, net: string, vat: string, gross: string}>
Groups all cart items by their effective VAT rate and returns formatted totals per rate, suitable for printing on fiscal receipts.
$breakdown = Cart::vatBreakdown(); // Example output for a cart with 22% and 10% VAT items: // [ // ['rate' => 22.0, 'net' => '100.00', 'vat' => '22.00', 'gross' => '122.00'], // ['rate' => 10.0, 'net' => '50.00', 'vat' => '5.00', 'gross' => '55.00'], // ] foreach ($breakdown as $row) { echo "VAT {$row['rate']}%: net {$row['net']}, vat {$row['vat']}, gross {$row['gross']}"; }
Phantom discount items (added via applyGlobalCoupon) are excluded from the breakdown. Rounding is controlled by config('cart.rounding_mode').
Per-item tax rate override:
Pass tax_rate in the options array to override the calculated VAT rate for a specific item. Useful when different products carry different VAT rates within the same cart.
// Item where price is net (ex-VAT) and tax_rate applies Cart::add( id: 2, name: 'Reduced-rate Item', subtitle: '', qty: 1, price: 100.0, totalPrice: 100.0, // will be recalculated from tax_rate vat: 0.0, options: ['tax_rate' => 10.0], // overrides VAT rate to 10% ); // resulting item: vatRate=10.0, vat=10.0, totalPrice=110.0
Database Persistence
Storing the Cart
Cart::store(mixed $identifier): void
Serializes the current cart instance to the database table defined in config('cart.database.table').
Cart::store(auth()->id());
Throws: CartAlreadyStoredException if a cart with the same identifier is already in the table.
Restoring the Cart
Cart::restore(mixed $identifier, bool $mergeOnRestore = false): void
Loads a stored cart from the database and deletes the database row. Returns silently if the identifier does not exist.
$mergeOnRestore = false(default): Replaces the current session cart with the stored cart.$mergeOnRestore = true: Merges the stored cart into the current session cart. Items already present (samerowId) are kept as-is; only new rows are added.
Cart-level coupons stored with the cart are automatically restored.
// Replace current cart with stored cart Cart::restore(auth()->id()); // Merge stored cart into current session cart Cart::restore(auth()->id(), mergeOnRestore: true);
Migrations
Publish and run the package migrations before using store() and restore():
php artisan vendor:publish --tag=cart-migrations php artisan migrate
The migration creates the table specified in config('cart.database.table') (default: cart).
Events
The package dispatches both typed event objects and legacy string events. By default both are dispatched (use_legacy_events = true). Set use_legacy_events = false in config to dispatch only typed events.
Typed Events
| Class | Fired when | Properties |
|---|---|---|
CartItemAdded |
An item is added | CartItem $cartItem |
CartItemUpdated |
An item is updated | CartItem $cartItem |
CartItemRemoved |
An item is removed | CartItem $cartItem |
CartStored |
The cart is stored to the database | mixed $identifier, string $instance |
CartRestored |
The cart is restored from the database | mixed $identifier, string $instance |
CouponApplied |
A coupon is added via addCoupon() |
CartCoupon $coupon, string $cartInstance |
CouponRemoved |
A coupon is removed via removeCartCoupon() |
CartCoupon $coupon, string $cartInstance |
All typed events are final readonly classes in OfflineAgency\LaravelCart\Events\.
Legacy String Events
| Event string | Fired when | Listener receives |
|---|---|---|
cart.added |
An item is added | CartItem $item |
cart.updated |
An item is updated | CartItem $item |
cart.removed |
An item is removed | CartItem $item |
cart.stored |
The cart is stored | (nothing) |
cart.restored |
The cart is restored | (nothing) |
cart.coupon_removed |
A per-item coupon is removed via removeCoupon() |
string $couponCode |
cart.coupons_cleared |
All per-item coupons removed via removeAllCoupons() |
(nothing) |
cart.global_coupon_added |
A global coupon is added via addGlobalCoupon() |
CartCoupon $coupon |
cart.global_coupon_removed |
A global coupon is removed via removeGlobalCoupon() |
CartCoupon $coupon |
Listening to typed events:
use OfflineAgency\LaravelCart\Events\CartItemAdded; use OfflineAgency\LaravelCart\Events\CouponApplied; // In EventServiceProvider or using #[AsEventListener] protected $listen = [ CartItemAdded::class => [ \App\Listeners\UpdateCartCountCache::class, ], CouponApplied::class => [ \App\Listeners\LogCouponUsage::class, ], // legacy string events still work when use_legacy_events = true 'cart.added' => [ \App\Listeners\LegacyListener::class, ], ];
Disabling legacy string events:
// config/cart.php 'use_legacy_events' => false, // only typed events are dispatched
Logout handling: When destroy_on_logout = true in config, the service provider listens to Illuminate\Auth\Events\Logout and calls Cart::instance()->destroy() automatically.
Exceptions
InvalidRowIDException
Class: OfflineAgency\LaravelCart\Exceptions\InvalidRowIDException
Thrown by Cart::get(), Cart::update(), Cart::remove(), and Cart::associate() when the given rowId does not exist in the current cart instance.
use OfflineAgency\LaravelCart\Exceptions\InvalidRowIDException; try { $item = Cart::get('non-existing-row-id'); } catch (InvalidRowIDException $e) { // The rowId is not in the cart report($e); }
CartAlreadyStoredException
Class: OfflineAgency\LaravelCart\Exceptions\CartAlreadyStoredException
Thrown by Cart::store() when a cart with the given identifier already exists in the database table.
use OfflineAgency\LaravelCart\Exceptions\CartAlreadyStoredException; try { Cart::store(auth()->id()); } catch (CartAlreadyStoredException $e) { // A stored cart already exists for this user // Consider deleting the old row first or using restore() + store() }
UnknownModelException
Class: OfflineAgency\LaravelCart\Exceptions\UnknownModelException
Thrown by Cart::associate() when a string class name is supplied that does not exist.
use OfflineAgency\LaravelCart\Exceptions\UnknownModelException; try { Cart::associate($item->rowId, 'App\Models\NonExistingProduct'); } catch (UnknownModelException $e) { // The model class does not exist }
InvalidCouponHashException
Class: OfflineAgency\LaravelCart\Exceptions\InvalidCouponHashException
Thrown by Cart::removeCoupon() when the coupon code is not found on any item, and by Cart::removeGlobalCoupon() when the hash is not in the global coupons collection.
use OfflineAgency\LaravelCart\Exceptions\InvalidCouponHashException; try { Cart::removeCoupon('EXPIRED_CODE'); } catch (InvalidCouponHashException $e) { // Coupon not found in cart } try { Cart::removeGlobalCoupon('stale-hash'); } catch (InvalidCouponHashException $e) { // Global coupon not found }
Artisan Commands
cart:clear
php artisan cart:clear [--force] [--instance=<name>]
Clears stored carts from the database table. Without --force the command prompts for confirmation. Use --instance to limit deletion to a specific cart instance.
# Clear all stored carts (with confirmation prompt) php artisan cart:clear # Skip confirmation php artisan cart:clear --force # Clear only stored carts for the 'wishlist' instance php artisan cart:clear --instance=wishlist --force
Testing Your Application
Cart::fake() Test Helper
Cart::fake() creates an in-memory Cart instance and swaps the container binding so the Facade resolves the same fake object. No database or real session is needed.
use OfflineAgency\LaravelCart\Facades\Cart; it('totals are correct', function () { Cart::fake(); Cart::add('1', 'Alpha', '', 2, 10.0, 12.2, 2.2); Cart::add('2', 'Beta', '', 1, 5.0, 6.1, 1.1); expect(Cart::count())->toBe(3) ->and(Cart::uniqueCount())->toBe(2) ->and(Cart::isEmpty())->toBeFalse(); });
Cart::fake() returns the Cart instance so you can keep a reference:
$fake = Cart::fake(); Cart::add('1', 'Item', '', 1, 9.99, 12.19, 2.20); expect($fake->count())->toBe(1);
Using FeatureTestCase
Extend the package's own FeatureTestCase in your Pest tests:
// tests/Pest.php use OfflineAgency\LaravelCart\Tests\FeatureTestCase; uses(FeatureTestCase::class)->in('.');
FeatureTestCase configures SQLite in-memory, the array session driver, and loads package migrations automatically.
Testing Cart Operations
use OfflineAgency\LaravelCart\Facades\Cart; it('adds an item and returns the correct total', function () { $item = Cart::add(1, 'Shirt', '', 2, 19.67, 24.00, 4.33); expect(Cart::count())->toBe(2) ->and(Cart::total())->toBe(48.0) ->and(Cart::vat())->toBe(8.66); }); it('removes an item from the cart', function () { $item = Cart::add(1, 'Shirt', '', 1, 19.67, 24.00, 4.33); Cart::remove($item->rowId); expect(Cart::content())->toBeEmpty(); });
Testing with Cart Instances
it('keeps wishlist and shopping cart separate', function () { Cart::instance('shopping')->add(1, 'Shirt', '', 1, 19.67, 24.00, 4.33); Cart::instance('wishlist')->add(2, 'Hat', '', 1, 12.30, 15.00, 2.70); expect(Cart::instance('shopping')->count())->toBe(1) ->and(Cart::instance('wishlist')->count())->toBe(1); });
Testing Event Listeners
use Illuminate\Support\Facades\Event; use OfflineAgency\LaravelCart\Events\CartItemAdded; use OfflineAgency\LaravelCart\Events\CouponApplied; use OfflineAgency\LaravelCart\CartCoupon; it('dispatches CartItemAdded typed event', function () { Event::fake(); $this->app->forgetInstance('cart'); $cart = $this->app->make('cart'); $cart->add(1, 'Shirt', '', 1, 19.67, 24.00, 4.33); Event::assertDispatched(CartItemAdded::class); Event::assertDispatched('cart.added'); // legacy string also dispatched when use_legacy_events=true }); it('dispatches CouponApplied when addCoupon is called', function () { Event::fake(); $this->app->forgetInstance('cart'); $cart = $this->app->make('cart'); $coupon = new CartCoupon(hash: 'h1', code: 'SAVE10', type: 'fixed', value: 10.0, isGlobal: true); $cart->addCoupon($coupon); Event::assertDispatched(CouponApplied::class, fn (CouponApplied $e) => $e->coupon->code === 'SAVE10'); });
CouponAlreadyAppliedException
Class: OfflineAgency\LaravelCart\Exceptions\CouponAlreadyAppliedException
Thrown by Cart::addCoupon() when a coupon with the same hash is already in the cart.
use OfflineAgency\LaravelCart\Exceptions\CouponAlreadyAppliedException; try { Cart::addCoupon($coupon); Cart::addCoupon($coupon); // duplicate hash } catch (CouponAlreadyAppliedException $e) { echo $e->couponCode; // the duplicate coupon code }
CouponNotFoundException
Class: OfflineAgency\LaravelCart\Exceptions\CouponNotFoundException
Thrown by Cart::removeCartCoupon() when the hash or code is not found.
use OfflineAgency\LaravelCart\Exceptions\CouponNotFoundException; try { Cart::removeCartCoupon('NONEXISTENT'); } catch (CouponNotFoundException $e) { // $e->couponCode contains the searched value }
InvalidCouponException
Class: OfflineAgency\LaravelCart\Exceptions\InvalidCouponException
Thrown by Cart::addCoupon() when a coupon fails validation: the coupon is expired (isExpired() returns true) or the cart total is below the required minCartAmount.
use OfflineAgency\LaravelCart\Exceptions\InvalidCouponException; try { Cart::addCoupon($expiredCoupon); } catch (InvalidCouponException $e) { // $e->couponCode contains the rejected coupon code }
Upgrade Guide
v3.x → v4.x
PHP and Laravel: PHP minimum stays at 8.2. Laravel 12 is now the primary target.
CartCoupon is now final readonly: Any code that mutates CartCoupon properties directly after construction will throw an Error. Use addGlobalCoupon() to create new coupons instead.
applyGlobalCoupon() is deprecated: Replace calls to Cart::applyCoupon($rowId = null, ...) with Cart::addGlobalCoupon() or the new Cart::addCoupon():
// Before (deprecated) Cart::applyCoupon(null, 'PROMO10', 'percentage', 10.0); // After (legacy API) Cart::addGlobalCoupon('unique-hash', 'PROMO10', 'percentage', 10.0); // After (new API — supports expiry, minCartAmount, typed events) Cart::addCoupon(new CartCoupon(hash: 'unique-hash', code: 'PROMO10', type: 'percentage', value: 10.0, isGlobal: true));
Cart::applyCoupon($rowId, ...) is deprecated for item-level use. Replace with Cart::addItemCoupon($rowId, ...).
New config keys: Publish the updated config and add the two new keys, or set defaults in your config/cart.php:
'use_legacy_events' => true, 'rounding_mode' => PHP_ROUND_HALF_UP,
Typed events: The package now dispatches typed event objects (CartItemAdded, CouponApplied, etc.) alongside legacy string events. Legacy string events remain active when use_legacy_events = true (the default). No immediate action required.
Migrations: Run php artisan vendor:publish --tag=cart-migrations && php artisan migrate after upgrading. A new coupons column (nullable JSON) is added to the cart table to persist cart-level coupons across store/restore cycles.
Global coupon session key: Global coupons are now stored under a separate session key (cart.{instance}_global_coupons). Existing sessions from v3.x that relied on the legacy applyCoupon(null, ...) discountCartItem approach will not carry forward global coupons automatically.
FAQ & Troubleshooting
My cart is empty after a redirect — why?
The most common cause is a misconfigured session driver. Verify that SESSION_DRIVER in your .env is not array (which resets on every request). Use file, cookie, database, or redis in production. A second cause is calling Cart::instance('name') before the redirect but accessing Cart::instance() (the default) after it — always use the same instance name across requests.
Two identical products appear as one row — is that a bug?
No. When two items share the same id and the same options, they produce the same rowId (MD5 of $id . serialize($options)). The cart merges them by summing quantities. To force separate rows, pass a differentiating option:
Cart::add(5, 'Shirt', '', 1, 19.67, 24.00, 4.33, '', '', '', ['size' => 'M']); Cart::add(5, 'Shirt', '', 1, 19.67, 24.00, 4.33, '', '', '', ['size' => 'L']); // Two separate rows because options differ
Prices are not rounding correctly on the receipt.
All internal calculations use formatFloat(), which rounds to 2 decimal places using number_format($value, 2, '.', ''). Display formatting is controlled by config('cart.format.decimals'), config('cart.format.decimal_point'), and config('cart.format.thousand_separator'). If your receipt totals do not match, confirm that price + vat = totalPrice for each item before adding it to the cart, because the package stores all three values as provided and derives vatRate from them.
Cart::total() returns 0 even though I added items.
Two common causes:
-
Facade not resolving: Confirm the package is auto-discovered (check
php artisan package:discover). If you disabled auto-discovery, addOfflineAgency\LaravelCart\CartServiceProvidertoconfig/app.phpproviders. -
Wrong instance: If you added items with
Cart::instance('shopping')->add(...)but callCart::total()without switching back to the same instance, the default instance is empty. Always callCart::instance('shopping')->total().
Can I use the cart without a database?
Yes. Cart::store() and Cart::restore() are optional. The cart runs entirely on the session by default. You only need the migration if you call those two methods.
How do I reset the cart after checkout?
Cart::destroy(); // Or for a named instance: Cart::instance('shopping')->destroy();
destroy() removes all items, global coupons, and cart options for the current instance.
Contributing
Please see CONTRIBUTING for details.
Security
If you discover any security-related issues, please email support@offlineagency.com instead of using the issue tracker.
Changelog
Please see CHANGELOG for recent changes, or the GitHub Releases page for full version history.
Credits
Offline Agency is a web design agency based in Padua, Italy. See offlineagency.it for an overview of their projects.
License
The MIT License (MIT). Please see License File for more information.