DEV Community

Cover image for What Building a Simple E-commerce Cart Taught Me About Senior Laravel Engineering
Olusola Ojewunmi
Olusola Ojewunmi

Posted on

What Building a Simple E-commerce Cart Taught Me About Senior Laravel Engineering

TL;DR: I built a production-grade e-commerce cart in 14 hours to explore Laravel Volt. This article focuses on the engineering decisions that matter: concurrency handling, partial failure recovery, data snapshotting, and query optimization. Full source code available on GitHub.


🎯 Context

Over the holiday break, I carved out a focused window to build something I'd been meaning to explore: a production-grade e-commerce cart using Laravel Volt. The entire projectβ€”from first commit to final polishβ€”took roughly 14 hours spread across December 29-30, 2025.

This wasn't a side project for fun. It was part of a technical assessment where I was given flexibility to choose my stack. I opted to try Laravel Volt for the first time, knowing it would be a learning experience. Ultimately, the team was looking for a different implementation approach, and we parted ways amicably. But the exercise itself was valuableβ€”it forced me to think deeply about correctness, concurrency, and the kind of engineering judgment that separates junior implementations from senior ones.

πŸ“¦ The complete source code is available here: https://github.com/Ojsholly/laravel-ecommerce-cart

This article isn't about the UI or feature completeness. It's about the backend decisions that matter when you're building systems that handle real money, real inventory, and real user expectations.


πŸ› οΈ Tech Stack

Layer Technology
Framework Laravel 12
Language PHP 8.4
Frontend Livewire 3 + Volt (first time using Volt)
Styling Tailwind CSS
Database (Dev) PostgreSQL
Database (Test) SQLite (in-memory)
Background Jobs Laravel Queues & Scheduler
Testing Pest
Version Control GitHub

The choice of Volt was deliberateβ€”I wanted to see how it felt to build reactive components without writing explicit Livewire classes. The learning curve was steeper than expected, but it paid off in reduced boilerplate.


🎯 The Problems That Matter

When you're building an e-commerce cart, the interesting problems aren't in the UI. They're in the edge cases:

  • ⚑ What happens when two users try to buy the last item simultaneously?
  • πŸ›’ How do you handle partial checkout when some items go out of stock mid-session?
  • πŸ“§ How do you avoid spamming admins with low-stock notifications?
  • πŸ“Έ How do you ensure historical order data remains accurate even if products change?

These are the problems that junior engineers often missβ€”and the ones that cause production incidents.


1. ⚑ Concurrency & Row-Level Locking

The most critical part of checkout is stock validation. If you don't lock rows properly, you'll oversell inventory.

The Problem: Race Conditions

Here's the naive approach (don't do this):

// ❌ Race condition: two requests can both see stock = 1
$product = Product::find($productId);
if ($product->stock_quantity >= $quantity) {
    $product->decrement('stock_quantity', $quantity);
}
Enter fullscreen mode Exit fullscreen mode

The problem: Between checking stock and decrementing it, another request can sneak in. Both requests see stock_quantity = 1, both proceed, and you've just sold two units of an item you only had one of.

Visual Breakdown of the Race Condition

Timeline User A User B Database Stock
T1 Read stock β†’ sees 1 1
T2 Read stock β†’ sees 1 1
T3 Check: 1 >= 1 βœ… 1
T4 Check: 1 >= 1 βœ… 1
T5 Decrement β†’ stock = 0 0
T6 Decrement β†’ stock = -1 ❌ -1

Result: Both purchases succeed, but inventory is now negative. You've oversold.

The Solution: Pessimistic Locking

// βœ… Lock the row for the duration of the transaction
$product = Product::lockForUpdate()->find($productId);

if ($product && $product->hasStock($quantity)) {
    $product->decrement('stock_quantity', $quantity);
}
Enter fullscreen mode Exit fullscreen mode

This is wrapped in a database transaction, so the lock is held until the transaction commits. No other request can read or modify that row until we're done.

Full Checkout Implementation

public function processCheckout(Cart $cart): Order
{
    return DB::transaction(function () use ($cart) {
        [$availableItems, $unavailableItems] = $this->validateStock($cart);

        if ($availableItems->isEmpty()) {
            throw new InsufficientStockException('All items are out of stock.');
        }

        $order = Order::create([/* ... */]);

        foreach ($availableItems as $item) {
            $this->createOrderItem($order, $item);
            $this->decrementStock($item->product, $item->quantity);
        }

        $this->removeAvailableItemsFromCart($cart, $availableItems);

        return $order;
    });
}

private function validateStock(Cart $cart): array
{
    $availableItems = collect();
    $unavailableItems = collect();

    foreach ($cart->items as $item) {
        $product = Product::lockForUpdate()->find($item->product_id);

        if ($product && $product->hasStock($item->quantity)) {
            $availableItems->push($item);
        } else {
            $unavailableItems->push($item);
        }
    }

    return [$availableItems, $unavailableItems];
}
Enter fullscreen mode Exit fullscreen mode

πŸ’‘ Key Takeaway: Without row-level locking, you'll have angry customers who paid for items you don't have. With it, you have a system that behaves correctly under load.


2. πŸ›’ Handling Partial Failure Gracefully

Here's a scenario that trips up a lot of implementations:

  1. User adds 5 items to their cart
  2. Another user buys one of those items, depleting stock
  3. First user tries to checkout

What should happen?

Approach Junior Implementation ❌ Senior Implementation βœ…
Error Handling Fail entire checkout with generic error Identify which items are available
Cart Management Delete all items (losing user intent) Process only available items
Stock Handling Proceed anyway and oversell Leave unavailable items in cart
User Feedback Confusing error message Clear feedback on what was purchased

Implementation

// Separate available from unavailable items
[$availableItems, $unavailableItems] = $this->validateStock($cart);

// Only process available items
$pricing = $this->priceCalculationService->calculateOrderPricing($availableItems);

// Remove only what was purchased
$this->removeAvailableItemsFromCart($cart, $availableItems);
Enter fullscreen mode Exit fullscreen mode

The key insight: Preserve user intent. If they wanted 5 items and only 4 are available, sell them 4 and keep the 5th in their cart. Don't make them start over.

Frontend Handling

On the frontend, unavailable items are clearly distinguished:

  • βœ… Visual indicators (opacity, grayscale images)
  • βœ… "Out of Stock" badges
  • βœ… Excluded from pricing calculations
  • βœ… Checkout button disabled if all items are unavailable

πŸ’‘ Key Takeaway: This isn't just good UXβ€”it's correct behavior. Partial failure is a reality in distributed systems. Handle it gracefully.


3. πŸ“Έ Snapshotting Data for Historical Accuracy

Here's a mistake I see often: storing only foreign keys in order items.

// ❌ What happens if the product is deleted or its price changes?
OrderItem::create([
    'order_id' => $order->id,
    'product_id' => $product->id,
    'quantity' => $quantity,
]);
Enter fullscreen mode Exit fullscreen mode

Six months later, the product is deleted or repriced. Now your order history is broken. You can't show customers what they actually paid for.

The fix: snapshot the product data at the time of purchase.

// βœ… Preserve historical accuracy
OrderItem::create([
    'order_id' => $order->id,
    'product_id' => $product->id,
    'quantity' => $quantity,
    'price_snapshot' => $product->price,
    'product_snapshot' => $product->toSnapshot(),
]);
Enter fullscreen mode Exit fullscreen mode

The toSnapshot() method captures everything needed to reconstruct the order:

public function toSnapshot(): array
{
    return [
        'id' => $this->id,
        'uuid' => $this->uuid,
        'name' => $this->name,
        'description' => $this->description,
        'price' => $this->price,
        'images' => $this->images,
        'primary_image' => $this->primary_image,
    ];
}
Enter fullscreen mode Exit fullscreen mode

πŸ’‘ Key Takeaway: Orders are legal documents. They need to be immutable and accurate, even if the underlying product data changes or disappears.


4. πŸ“§ Background Jobs & Avoiding Notification Spam

Low-stock notifications are a common feature. The naive implementation:

// ❌ Sends a notification every time stock decrements below threshold
if ($product->stock_quantity <= $lowStockThreshold) {
    SendLowStockNotification::dispatch($product);
}
Enter fullscreen mode Exit fullscreen mode

The problem: if stock is at 5 and the threshold is 10, you'll send a notification on every sale until stock hits 0. That's 5 emails for the same issue.

The fix: only notify when crossing the threshold.

private function decrementStock(Product $product, int $quantity): void
{
    $lowStockThreshold = config('cart.low_stock_threshold', 10);
    $stockBeforeDecrement = $product->stock_quantity;

    $product->decrement('stock_quantity', $quantity);

    $stockAfterDecrement = $product->fresh()->stock_quantity;

    // Only notify when crossing from above to below threshold
    if ($stockBeforeDecrement > $lowStockThreshold 
        && $stockAfterDecrement <= $lowStockThreshold) {
        SendLowStockNotification::dispatch($product);
    }
}
Enter fullscreen mode Exit fullscreen mode

πŸ’‘ Key Takeaway: Notification fatigue is real. Admins will ignore your alerts if you spam them. Send one notification per threshold crossing, not one per sale.


5. πŸ“Š Daily Sales Reports & Query Optimization

Another common feature: daily sales reports sent via email. The challenge is aggregating data efficiently without N+1 queries.

Here's the job structure:

public function handle(): void
{
    $stats = $this->calculateStats();
    $recentOrders = $this->getRecentOrders();
    $topProducts = $this->getTopSellingProducts();

    Mail::to($adminEmail)->send(
        new DailySalesReport($stats, $recentOrders, $topProducts, $this->date)
    );
}
Enter fullscreen mode Exit fullscreen mode

The interesting part is getTopSellingProducts(). You need to:

  1. Aggregate sales by product
  2. Fetch product snapshots (since products might have changed)
  3. Avoid N+1 queries

Here's how I did it:

private function getTopSellingProducts(): Collection
{
    // Single query to get aggregated sales data
    $topProductsData = Order::completed()
        ->whereDate('orders.created_at', $this->date)
        ->join('order_items', 'orders.id', '=', 'order_items.order_id')
        ->selectRaw('
            order_items.product_id, 
            sum(order_items.quantity) as total_quantity, 
            sum(order_items.quantity * order_items.price_snapshot) as total_sales
        ')
        ->groupBy('order_items.product_id')
        ->orderByDesc('total_quantity')
        ->limit(5)
        ->get();

    // Single query to fetch order items (which contain snapshots)
    $orderItemsByProductId = OrderItem::whereIn('product_id', $topProductsData->pluck('product_id'))
        ->whereHas('order', fn($q) => $q->completed()->whereDate('created_at', $this->date))
        ->get()
        ->keyBy('product_id');

    // Map aggregated data to snapshots
    return $topProductsData->map(function ($item) use ($orderItemsByProductId) {
        $orderItem = $orderItemsByProductId->get($item->product_id);
        $snapshot = $orderItem->product_snapshot ?? [];

        return [
            'name' => $snapshot['name'] ?? 'Unknown Product',
            'quantity' => $item->total_quantity,
            'revenue' => number_format((float) $item->total_sales, 2),
        ];
    });
}
Enter fullscreen mode Exit fullscreen mode

πŸ’‘ Key Takeaway: N+1 queries kill performance. Two queries for any number of products is better than N+1 queries for N products.


6. πŸ—οΈ Separation of Concerns

One pattern I stuck to throughout: keep business logic out of controllers and Livewire components.

Components should be thin:

public function placeOrder(): void
{
    try {
        $checkoutService = app(CheckoutService::class);
        $order = $checkoutService->processCheckout($this->cart);

        $this->dispatch('cart-updated');
        $this->dispatch('notify', message: 'Order placed successfully!', type: 'success');
        $this->redirect(route('orders.show', $order), navigate: true);
    } catch (\Exception $e) {
        $this->dispatch('notify', message: $e->getMessage(), type: 'error');
    }
}
Enter fullscreen mode Exit fullscreen mode

All the complexity lives in CheckoutService. This makes testing easier, logic reusable, and the codebase more maintainable.


πŸ’­ What I Learned About Volt

This was my first time using Laravel Volt, and it was a mixed experience.

What I liked:

  • Less boilerplate than traditional Livewire classes
  • Faster iteration for simple components
  • Good for prototyping

What I found challenging:

  • Computed properties require specific naming conventions (getXProperty())
  • Debugging is harder without explicit class files
  • IDE support is weaker than traditional Livewire

Would I use it again? For rapid prototyping, yes. For a large production app, I'd probably stick with traditional Livewire classes for better tooling and discoverability.


πŸŽ“ Reflections

Building this cart in 14 hours was a forcing function. I couldn't afford to bikeshed or over-engineer. I had to focus on correctness first, polish second.

The things that mattered:

  • βœ… Transactions and locking
  • βœ… Handling partial failure gracefully
  • βœ… Snapshotting data for historical accuracy
  • βœ… Avoiding notification spam
  • βœ… Query optimization

The things that didn't matter (in this timeframe):

  • ⏸️ Perfect UI polish
  • ⏸️ Comprehensive admin dashboards
  • ⏸️ Payment gateway integration

πŸ’‘ Key Takeaway: This is the kind of prioritization that defines senior engineering: knowing what to build, what to defer, and what to skip entirely.


🎯 Final Thoughts

The team ultimately went with a different tech stack, which is fine. Not every project is a fit, and that's okay. But the exercise itself was valuableβ€”it forced me to think through problems I don't encounter in my day-to-day work, and it gave me a chance to explore Volt in a real-world context.

The repo is open source. Star it if you find the locking logic useful: https://github.com/Ojsholly/laravel-ecommerce-cart

The codebase includes:

  • βœ… 123 passing tests (Pest)
  • βœ… Full checkout flow with concurrency handling
  • βœ… Background jobs for notifications and reports
  • βœ… Partial checkout support
  • βœ… Product snapshotting
  • βœ… Query optimization

It's not perfect, but it's correct. And in production systems, correctness is what matters.


πŸ”— Links & Resources

  • πŸ“¦ GitHub Repository: https://github.com/Ojsholly/laravel-ecommerce-cart
  • ⭐ Star the repo if you found the locking logic or partial failure handling useful
  • πŸ’¬ Questions? Feel free to reach outβ€”I'm always happy to discuss Laravel architecture and engineering tradeoffs

Thanks for reading! If this helped you think differently about e-commerce cart implementation, consider sharing it with your team.

Top comments (0)