laravel-cart maintained by offline-agency
Laravel Cart
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.