LaravelPackages.net
Acme Inc.
Toggle sidebar
offline-agency/laravel-cart

Laravel shopping cart with fiscal support

1.655
2
3.2.0
About offline-agency/laravel-cart

offline-agency/laravel-cart is a Laravel package for laravel shopping cart with fiscal support. It currently has 2 GitHub stars and 1.655 downloads on Packagist (latest version 3.2.0). Install it with composer require offline-agency/laravel-cart. Discover more Laravel packages by offline-agency or browse all Laravel packages to compare alternatives.

Last updated

Laravel Cart

Latest Stable Version Total Downloads CI MIT Licensed Pint codecov

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.4 or higher is required.

| Laravel | PHP | Package | |---------|-------|---------| | 10.x | ^8.4 | ^3.x | | 11.x | ^8.4 | ^3.x | | 12.x | ^8.4 | ^4.x | | 13.x | ^8.4 | ^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 $couponValue from totalPrice, then back-calculates price and vat
  • 'percentage': deducts ($couponValue / 100) × originalTotalPrice from totalPrice, then back-calculates price and vat

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 (same rowId) 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:

  1. Facade not resolving: Confirm the package is auto-discovered (check php artisan package:discover). If you disabled auto-discovery, add OfflineAgency\LaravelCart\CartServiceProvider to config/app.php providers.

  2. Wrong instance: If you added items with Cart::instance('shopping')->add(...) but call Cart::total() without switching back to the same instance, the default instance is empty. Always call Cart::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 [email protected] 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.

Star History Chart