DEV Community

Cover image for Why I Built a Cart Package That Refuses to Store Prices
Pham Thao
Pham Thao

Posted on

Why I Built a Cart Package That Refuses to Store Prices

TL;DR: I built a Laravel cart package that never stores prices. Instead, it resolves them at runtime - eliminating price manipulation attacks and stale pricing bugs forever.


The Problem with Traditional Cart Packages

Every Laravel cart package I've used has the same fundamental flaw: they store prices at the moment an item is added to the cart.

This creates real problems:

// User adds item at $49.99
Cart::add($product); // Price stored: $49.99

// Admin updates price to $59.99
$product->update(['price' => 5999]);

// User checks out hours later... still pays $49.99
// Or worse: they inspect the DOM, find the stored price, manipulate it
Enter fullscreen mode Exit fullscreen mode

Stale prices. Price manipulation vulnerabilities. Race conditions during flash sales.

I decided to fix this properly.


Introducing Cartly: Real-Time Price Resolution for Laravel

After months of development, I'm releasing Cartly - a shopping cart library that takes a fundamentally different approach.

composer require vanthao03596/cartly
Enter fullscreen mode Exit fullscreen mode

Core principle: Prices are never stored. They're resolved at checkout time, every time.

use Cart\Cart;

// Add to cart - no price stored
$item = Cart::add($product, quantity: 2);

// Price resolved fresh when accessed
$subtotal = Cart::subtotal(); // Always current
$total = Cart::total();       // Always accurate
Enter fullscreen mode Exit fullscreen mode

Why Real-Time Resolution Matters

1. Security by Design

Price manipulation attacks become impossible. There's no stored price to tamper with.

// Traditional package - vulnerable
$_SESSION['cart']['item_1']['price'] = 100; // Attacker changes price

// Cartly - no price to manipulate
// Price is resolved from your trusted source at runtime
Enter fullscreen mode Exit fullscreen mode

2. Flash Sales Actually Work

// Flash sale starts
$product->update(['price' => 1999]); // 50% off!

// All carts immediately reflect new price
// No "sorry, that sale ended" errors at checkout
Enter fullscreen mode Exit fullscreen mode

3. Context-Aware Pricing

Prices can vary by user, currency, locale, or any business logic:

class TieredPriceResolver implements PriceResolver
{
    public function resolve(CartItem $item, CartContext $context): ResolvedPrice
    {
        $user = $context->user();

        return match(true) {
            $user?->isPremium() => new ResolvedPrice(cents: 1999),
            $user?->isWholesale() => new ResolvedPrice(cents: 1499),
            default => new ResolvedPrice(cents: 2499),
        };
    }
}
Enter fullscreen mode Exit fullscreen mode

Performance: Batch Resolution Eliminates N+1

"But resolving prices at runtime must be slow!"

Not with batch resolution:

// Traditional approach - N+1 queries
foreach ($cart->items() as $item) {
    $price = $item->product->price; // Query per item
}

// Cartly - Single batch query
public function resolveMany(CartItemCollection $items, CartContext $context): array
{
    $ids = $items->pluck('id')->all();
    $prices = Price::whereIn('product_id', $ids)->get()->keyBy('product_id');

    return $items->mapWithKeys(fn($item) => [
        $item->rowId => new ResolvedPrice(cents: $prices[$item->id]->amount)
    ])->all();
}
Enter fullscreen mode Exit fullscreen mode

Cartly automatically batch-loads models and prices. One query for models, one for prices - regardless of cart size.


Features That Actually Matter

Multiple Cart Instances

Separate cart, wishlist, and compare lists with full isolation:

// Main cart
Cart::add($product);

// Wishlist
Cart::wishlist()->add($product);

// Compare (limited to 4 items)
Cart::compare()->add($product);

// Move between instances
Cart::moveToWishlist($rowId);
Cart::moveToCart($rowId);
Enter fullscreen mode Exit fullscreen mode

Flexible Conditions System

Tax, discounts, shipping - with explicit ordering control:

use Cart\Conditions\TaxCondition;
use Cart\Conditions\DiscountCondition;

// 20% discount applied first
Cart::condition(new DiscountCondition(
    name: 'Summer Sale',
    value: 20,
    mode: 'percentage',
    order: 1
));

// Tax calculated on discounted amount
Cart::condition(new TaxCondition(
    name: 'VAT',
    rate: 10,
    order: 2
));

// Get transparent breakdown
$breakdown = Cart::getCalculationBreakdown();
// [
//     'subtotal' => 10000,
//     'conditions' => [
//         'Summer Sale' => -2000,
//         'VAT' => 800,
//     ],
//     'total' => 8800
// ]
Enter fullscreen mode Exit fullscreen mode

Multiple Storage Drivers

Choose what fits your architecture:

Driver Best For
Session Stateless apps, quick setup
Database Persistent carts, user accounts
Cache High-performance, Redis/Memcached
Array Testing
// config/cart.php
'driver' => 'database',

// Or per-instance
'instances' => [
    'default' => ['driver' => 'database'],
    'compare' => ['driver' => 'session'],
]
Enter fullscreen mode Exit fullscreen mode

Guest-to-User Cart Merging

Automatic cart merging when users log in:

'associate' => [
    'auto_associate' => true,
    'merge_on_login' => true,
    'merge_strategy' => 'combine', // combine | keep_guest | keep_user
]
Enter fullscreen mode Exit fullscreen mode

Developer Experience

All Prices in Cents

No more floating-point precision bugs:

// Internal: everything in cents
$subtotal = Cart::subtotal(); // 4999 (not 49.99)

// Display: formatted for humans
$formatted = Cart::subtotal(formatted: true); // "$49.99"

// Helpers included
format_price(4999);        // "$49.99"
cents_to_dollars(4999);    // 49.99
dollars_to_cents(49.99);   // 4999
Enter fullscreen mode Exit fullscreen mode

First-Class Testing Support

use Cart\Cart;

public function test_checkout_calculates_correct_total()
{
    Cart::fake();

    Cart::factory()
        ->withItems([
            ['id' => 1, 'quantity' => 2, 'price' => 1000],
            ['id' => 2, 'quantity' => 1, 'price' => 2500],
        ])
        ->withCondition(new TaxCondition('VAT', 10))
        ->create();

    Cart::assertSubtotal(4500);
    Cart::assertTaxTotal(450);
    Cart::assertTotal(4950);
}
Enter fullscreen mode Exit fullscreen mode

Event-Driven Architecture

Hook into every cart operation:

// 12 events for complete lifecycle coverage
CartItemAdding / CartItemAdded
CartItemUpdating / CartItemUpdated
CartItemRemoving / CartItemRemoved
CartClearing / CartCleared
CartMerging / CartMerged
CartConditionAdded / CartConditionRemoved
Enter fullscreen mode Exit fullscreen mode

Quick Start

composer require vanthao03596/cartly
php artisan vendor:publish --provider="Cart\CartServiceProvider" --tag="config"
Enter fullscreen mode Exit fullscreen mode

Make your model buyable:

use Cart\Contracts\Buyable;
use Cart\Contracts\Priceable;
use Cart\Traits\CanBeBought;

class Product extends Model implements Buyable, Priceable
{
    use CanBeBought;

    // That's it - price auto-detected from common attributes
    // (price, sale_price, current_price, etc.)
}
Enter fullscreen mode Exit fullscreen mode

Start using it:

use Cart\Cart;

// Add items
Cart::add($product, quantity: 2, options: ['size' => 'L']);

// Get totals
$total = Cart::total(formatted: true); // "$99.98"

// Clear cart
Cart::clear();
Enter fullscreen mode Exit fullscreen mode

Why I Built This

I've built e-commerce systems for years. Every time, I'd either:

  1. Use an existing package and fight its limitations
  2. Build a custom cart from scratch

Neither was satisfying. Existing packages stored prices (security/freshness issues). Custom solutions meant reinventing the wheel every project.

Cartly is the cart library I wish I had 5 years ago:

  • Secure by default - no price storage means no manipulation
  • Always fresh - real-time resolution, not stale snapshots
  • Extensible - swap any component without forking
  • Well-tested - comprehensive test suite with testing utilities
  • Properly typed - full PHP 8.1+ type safety

Links


What's Next?

I'm actively maintaining this package and have plans for:

  • More built-in condition types
  • Livewire/Inertia component examples

Star the repo if this approach resonates with you. Feedback and contributions welcome!

Top comments (0)