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);
}
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);
}
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];
}
π‘ 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:
- User adds 5 items to their cart
- Another user buys one of those items, depleting stock
- 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);
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,
]);
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(),
]);
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,
];
}
π‘ 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);
}
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);
}
}
π‘ 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)
);
}
The interesting part is getTopSellingProducts(). You need to:
- Aggregate sales by product
- Fetch product snapshots (since products might have changed)
- 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),
];
});
}
π‘ 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');
}
}
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)