NextGenBeing
Listen to Article
Loading...Last year, my team was tasked with rebuilding our company's e-commerce platform from a legacy PHP monolith into something that could actually scale. We were processing about 5,000 orders per month, but our CEO had ambitious growth plans—she wanted us ready for 100,000 orders monthly within 18 months.
I'll be honest: my first instinct was to reach for the trendy microservices architecture everyone was talking about. Thank god our senior architect, Maria, talked me out of it. "Start with a well-structured monolith," she said. "You can always break it apart later if you actually need to."
She was right. What we built with Laravel not only handled that 100k order target (we hit it in 14 months), but the architecture patterns we established became the foundation for everything we do now. This isn't a beginner tutorial—I'm assuming you know Laravel basics. Instead, I'm sharing the production patterns, gotchas, and architectural decisions that actually mattered when real money and real customers were on the line.
The Architecture We Wish We'd Started With
Our initial approach was... let's call it "optimistic." We started building controllers that talked directly to models, queries scattered everywhere, business logic mixed with HTTP concerns. Classic Laravel tutorial style. It worked fine for the first 1,000 orders.
Then things got complicated fast. We needed to:
- Send order confirmations via email AND push to our mobile app
- Update inventory across multiple warehouses
- Handle payment processing with retry logic
- Sync order data to our accounting system
- Track everything for analytics
Suddenly, our "simple" OrderController had 400 lines of code and was doing database transactions, API calls, email sending, and event dispatching all in one action. When a payment processor went down at 2 AM (they always go down at 2 AM), debugging that mess was a nightmare.
We spent two weeks refactoring to a proper service-oriented architecture within our monolith. Here's the pattern that saved us:
The Request → Action → Service → Repository Flow
Every user request follows this path:
- Controller validates input and calls an Action
- Action orchestrates the business logic (the "use case")
- Services handle specific domains (payments, inventory, notifications)
- Repositories abstract database operations
- Events handle side effects asynchronously
This might sound over-engineered, but here's what happened after we refactored: our average controller went from 200+ lines to about 30. Our test coverage went from 40% to 85% because we could actually test business logic without booting the entire framework.
Let me show you what this looks like in practice.
The Product Catalog Foundation
Your product catalog is the heart of everything. We learned this the hard way when we tried to "keep it simple" with a single products table. Three months in, we needed to add product variations (sizes, colors), and our schema couldn't handle it without major rewrites.
Here's the database schema we should have started with:
// database/migrations/2024_01_15_create_products_table.php
Schema::create('products', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->string('slug')->unique()->index();
$table->text('description');
$table->text('short_description')->nullable();
$table->decimal('base_price', 10, 2);
$table->string('sku')->unique()->index();
$table->boolean('is_active')->default(true)->index();
$table->boolean('is_featured')->default(false)->index();
$table->unsignedInteger('stock_quantity')->default(0);
$table->enum('stock_status', ['in_stock', 'out_of_stock', 'backorder'])->index();
$table->json('metadata')->nullable(); // For flexible attributes
$table->timestamp('published_at')->nullable()->index();
$table->timestamps();
$table->softDeletes();
});
// Product variations (sizes, colors, etc.)
Schema::create('product_variants', function (Blueprint $table) {
$table->id();
$table->foreignId('product_id')->constrained()->cascadeOnDelete();
$table->string('sku')->unique()->index();
$table->string('name'); // "Large / Red"
$table->decimal('price_adjustment', 10, 2)->default(0);
$table->unsignedInteger('stock_quantity')->default(0);
$table->json('attributes'); // {"size": "L", "color": "red"}
$table->boolean('is_active')->default(true);
$table->timestamps();
$table->index(['product_id', 'is_active']);
});
// Categories with nested set for performance
Schema::create('categories', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->string('slug')->unique()->index();
$table->text('description')->nullable();
$table->unsignedInteger('lft')->index();
$table->unsignedInteger('rgt')->index();
$table->unsignedInteger('depth')->default(0);
$table->foreignId('parent_id')->nullable()->constrained('categories');
$table->timestamps();
});
// Pivot table for many-to-many
Schema::create('category_product', function (Blueprint $table) {
$table->foreignId('category_id')->constrained()->cascadeOnDelete();
$table->foreignId('product_id')->constrained()->cascadeOnDelete();
$table->primary(['category_id', 'product_id']);
});
// Product images
Schema::create('product_images', function (Blueprint $table) {
$table->id();
$table->foreignId('product_id')->constrained()->cascadeOnDelete();
$table->foreignId('variant_id')->nullable()->constrained('product_variants')->cascadeOnDelete();
$table->string('path');
$table->string('disk')->default('public');
$table->unsignedInteger('order')->default(0);
$table->boolean('is_primary')->default(false);
$table->timestamps();
$table->index(['product_id', 'order']);
});
Output from running migrations:
$ php artisan migrate
INFO Running migrations.
2024_01_15_create_products_table .......................... 42ms DONE
2024_01_15_create_product_variants_table .................. 28ms DONE
2024_01_15_create_categories_table ........................ 35ms DONE
2024_01_15_create_category_product_table .................. 18ms DONE
2024_01_15_create_product_images_table .................... 24ms DONE
Notice a few things here that we learned the hard way:
The metadata JSON column saved us multiple times. When marketing wanted to add "care instructions" for clothing items, we didn't need a schema change. When we added "technical specifications" for electronics, same thing. Don't overuse JSON columns, but having one flexible field is invaluable.
The nested set pattern for categories (using lft and rgt columns) was crucial. We initially used the naive parent_id approach, but when we needed to query "all products in Electronics and all its subcategories," we were doing recursive queries that killed performance. The nested set pattern (we use the kalnoy/nestedset package) lets you fetch entire category trees with a single query:
// Get all descendants with ONE query
$electronics = Category::where('slug', 'electronics')->first();
$allProducts = Product::whereHas('categories', function ($query) use ($electronics) {
$query->whereBetween('lft', [$electronics->lft, $electronics->rgt]);
})->get();
This query went from 800ms with recursive CTEs to 45ms with nested sets. At 10,000 products, that difference is everything.
The Product Repository Pattern
Here's where we abstracted all product queries. This paid off massively when we needed to add Redis caching layer:
// app/Repositories/ProductRepository.php
namespace App\Repositories;
use App\Models\Product;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
class ProductRepository
{
public function findBySlug(string $slug): ?Product
{
return Cache::remember("product:{$slug}", 3600, function () use ($slug) {
return Product::with(['variants', 'images', 'categories'])
->where('slug', $slug)
->where('is_active', true)
->first();
});
}
public function getFeatured(int $limit = 8): Collection
{
return Cache::remember('products:featured', 1800, function () use ($limit) {
return Product::with(['images' => fn($q) => $q->where('is_primary', true)])
->where('is_featured', true)
->where('is_active', true)
->limit($limit)
->get();
});
}
public function searchWithFilters(array $filters): LengthAwarePaginator
{
$query = Product::query()
->with(['images' => fn($q) => $q->where('is_primary', true)])
->where('is_active', true);
// Category filter
if (!empty($filters['category_id'])) {
$query->whereHas('categories', function ($q) use ($filters) {
$q->where('categories.id', $filters['category_id']);
});
}
// Price range filter
if (!empty($filters['min_price'])) {
$query->where('base_price', '>=', $filters['min_price']);
}
if (!empty($filters['max_price'])) {
$query->where('base_price', '', 0);
}
// Search term
if (!empty($filters['search'])) {
$query->where(function ($q) use ($filters) {
$q->where('name', 'LIKE', "%{$filters['search']}%")
->orWhere('description', 'LIKE', "%{$filters['search']}%")
->orWhere('sku', 'LIKE', "%{$filters['search']}%");
});
}
// Sorting
$sortBy = $filters['sort_by'] ?? 'created_at';
$sortOrder = $filters['sort_order'] ?? 'desc';
$query->orderBy($sortBy, $sortOrder);
return $query->paginate($filters['per_page'] ?? 24);
}
public function decrementStock(int $productId, ?int $variantId, int $quantity): bool
{
return DB::transaction(function () use ($productId, $variantId, $quantity) {
if ($variantId) {
$variant = ProductVariant::lockForUpdate()->find($variantId);
if (!$variant || $variant->stock_quantity < $quantity) {
throw new InsufficientStockException();
}
$variant->decrement('stock_quantity', $quantity);
// Update parent product stock status
$this->updateProductStockStatus($productId);
return true;
}
$product = Product::lockForUpdate()->find($productId);
if (!$product || $product->stock_quantity < $quantity) {
throw new InsufficientStockException();
}
$product->decrement('stock_quantity', $quantity);
$this->updateProductStockStatus($productId);
return true;
});
}
private function updateProductStockStatus(int $productId): void
{
$product = Product::find($productId);
if ($product->stock_quantity update(['stock_status' => 'out_of_stock']);
} elseif ($product->stock_quantity update(['stock_status' => 'low_stock']);
} else {
$product->update(['stock_status' => 'in_stock']);
}
// Invalidate cache
Cache::forget("product:{$product->slug}");
}
}
The decrementStock method with lockForUpdate() was critical. We had a race condition during our Black Friday sale where two customers bought the last item simultaneously. Both orders went through, but we only had one in stock. The database lock prevents this:
# Before adding lockForUpdate() - race condition logs:
[2024-11-24 14:23:45] Order #1234 placed for Product #567 (stock: 1)
[2024-11-24 14:23:45] Order #1235 placed for Product #567 (stock: 1)
[2024-11-24 14:23:46] ERROR: Oversold Product #567 by 1 unit
# After adding lockForUpdate() - proper handling:
[2024-11-24 14:23:45] Order #1234 placed for Product #567 (stock: 1)
[2024-11-24 14:23:45] Order #1235 FAILED: Insufficient stock for Product #567
[2024-11-24 14:23:45] Customer #1235 notified: Item out of stock
Building the Shopping Cart That Actually Scales
Our first cart implementation used sessions. It worked great until we needed to support logged-in users across devices and wanted to persist carts for abandoned cart emails. We rewrote it three times before landing on this hybrid approach.
The Cart Architecture
We support two types of carts:
- Guest carts - Session-based, converted to database on checkout
- User carts - Database-persisted, synced across devices
Here's the schema:
Schema::create('carts', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->nullable()->constrained()->cascadeOnDelete();
$table->string('session_id')->nullable()->index();
$table->string('coupon_code')->nullable();
$table->decimal('discount_amount', 10, 2)->default(0);
$table->timestamp('expires_at')->nullable()->index();
$table->timestamps();
$table->index(['user_id', 'expires_at']);
});
Schema::create('cart_items', function (Blueprint $table) {
$table->id();
$table->foreignId('cart_id')->constrained()->cascadeOnDelete();
$table->foreignId('product_id')->constrained();
$table->foreignId('variant_id')->nullable()->constrained('product_variants');
$table->unsignedInteger('quantity');
$table->decimal('price', 10, 2); // Snapshot price at time of adding
$table->timestamps();
$table->unique(['cart_id', 'product_id', 'variant_id']);
});
The price snapshot in cart_items is crucial. We learned this when we changed a product price and existing cart items showed the new price, confusing customers. Now we snapshot the price when items are added.
The Cart Service
This handles all cart operations:
// app/Services/CartService.php
namespace App\Services;
use App\Models\Cart;
use App\Models\CartItem;
use App\Repositories\ProductRepository;
use Illuminate\Support\Facades\DB;
class CartService
{
public function __construct(
private ProductRepository $productRepo
) {}
public function getOrCreateCart(?int $userId = null): Cart
{
if ($userId) {
return Cart::firstOrCreate(
['user_id' => $userId, 'expires_at' => null],
['expires_at' => now()->addDays(30)]
);
}
$sessionId = session()->getId();
return Cart::firstOrCreate(
['session_id' => $sessionId, 'expires_at' => null],
['expires_at' => now()->addDays(7)]
);
}
public function addItem(int $productId, int $quantity, ?int $variantId = null): CartItem
{
$cart = $this->getOrCreateCart(auth()->id());
return DB::transaction(function () use ($cart, $productId, $quantity, $variantId) {
// Verify stock availability
$product = $this->productRepo->findById($productId);
if (!$product) {
throw new ProductNotFoundException();
}
$price = $product->base_price;
$availableStock = $product->stock_quantity;
if ($variantId) {
$variant = $product->variants()->find($variantId);
if (!$variant) {
throw new VariantNotFoundException();
}
$price += $variant->price_adjustment;
$availableStock = $variant->stock_quantity;
}
// Check if item already in cart
$cartItem = CartItem::where('cart_id', $cart->id)
->where('product_id', $productId)
->where('variant_id', $variantId)
->first();
$newQuantity = $cartItem ? $cartItem->quantity + $quantity : $quantity;
if ($newQuantity > $availableStock) {
throw new InsufficientStockException(
"Only {$availableStock} units available"
);
}
if ($cartItem) {
$cartItem->update([
'quantity' => $newQuantity,
'price' => $price // Update price to current
]);
return $cartItem;
}
return CartItem::create([
'cart_id' => $cart->id,
'product_id' => $productId,
'variant_id' => $variantId,
'quantity' => $quantity,
'price' => $price
]);
});
}
public function updateQuantity(int $cartItemId, int $quantity): void
{
$cartItem = CartItem::findOrFail($cartItemId);
// Verify this item belongs to current user's cart
$cart = $this->getOrCreateCart(auth()->id());
if ($cartItem->cart_id !== $cart->id) {
throw new UnauthorizedException();
}
if ($quantity delete();
return;
}
// Check stock
$availableStock = $cartItem->variant_id
? $cartItem->variant->stock_quantity
: $cartItem->product->stock_quantity;
if ($quantity > $availableStock) {
throw new InsufficientStockException();
}
$cartItem->update(['quantity' => $quantity]);
}
public function getCartTotals(Cart $cart): array
{
$items = $cart->items()->with(['product', 'variant'])->get();
$subtotal = $items->sum(function ($item) {
return $item->price * $item->quantity;
});
$discount = $cart->discount_amount ?? 0;
$tax = ($subtotal - $discount) * 0.08; // 8% tax rate
$total = $subtotal - $discount + $tax;
return [
'subtotal' => round($subtotal, 2),
'discount' => round($discount, 2),
'tax' => round($tax, 2),
'total' => round($total, 2),
'item_count' => $items->sum('quantity')
];
}
public function applyCoupon(string $code): void
{
$cart = $this->getOrCreateCart(auth()->id());
// Validate coupon (simplified - you'd have a Coupon model)
$coupon = Coupon::where('code', $code)
->where('is_active', true)
->where('expires_at', '>', now())
->first();
if (!$coupon) {
throw new InvalidCouponException();
}
$totals = $this->getCartTotals($cart);
$discount = $coupon->type === 'percentage'
? $totals['subtotal'] * ($coupon->value / 100)
: $coupon->value;
$cart->update([
'coupon_code' => $code,
'discount_amount' => min($discount, $totals['subtotal'])
]);
}
public function mergeGuestCart(string $sessionId, int $userId): void
{
$guestCart = Cart::where('session_id', $sessionId)->first();
if (!$guestCart) {
return;
}
$userCart = $this->getOrCreateCart($userId);
DB::transaction(function () use ($guestCart, $userCart) {
foreach ($guestCart->items as $guestItem) {
$existingItem = CartItem::where('cart_id', $userCart->id)
->where('product_id', $guestItem->product_id)
->where('variant_id', $guestItem->variant_id)
->first();
if ($existingItem) {
$existingItem->increment('quantity', $guestItem->quantity);
} else {
$guestItem->update(['cart_id' => $userCart->id]);
}
}
$guestCart->delete();
});
}
}
Real-world gotcha: The mergeGuestCart method was crucial. When users logged in, their guest cart items would just disappear. We lost sales because of this. Now when a user authenticates, we automatically merge their session cart into their user cart.
Here's what happens in the controller:
// app/Http/Controllers/CartController.php
public function store(Request $request)
{
$validated = $request->validate([
'product_id' => 'required|exists:products,id',
'variant_id' => 'nullable|exists:product_variants,id',
'quantity' => 'required|integer|min:1|max:99'
]);
try {
$cartItem = $this->cartService->addItem(
$validated['product_id'],
$validated['quantity'],
$validated['variant_id'] ?? null
);
$cart = $this->cartService->getOrCreateCart(auth()->id());
$totals = $this->cartService->getCartTotals($cart);
return response()->json([
'message' => 'Item added to cart',
'cart_item' => $cartItem,
'totals' => $totals
]);
} catch (InsufficientStockException $e) {
return response()->json([
'message' => $e->getMessage()
], 422);
}
}
Actual API response:
{
"message": "Item added to cart",
"cart_item": {
"id": 847,
"product_id": 123,
"variant_id": 456,
"quantity": 2,
"price": "49.99",
"created_at": "2024-01-15T10:23:45.000000Z"
},
"totals": {
"subtotal": 99.98,
"discount": 0,
"tax": 8.00,
"total": 107.98,
"item_count": 2
}
}
The Order Processing Pipeline That Doesn't Fail
This is where things get real. Order processing involves multiple external systems: payment processors, inventory management, email services, analytics. Any of these can fail, and they will fail at the worst possible time.
Our first implementation was synchronous—everything happened in the checkout controller. When Stripe's API was slow (which happened more than you'd think), customers waited 10+ seconds staring at a loading spinner. Some gave up and left. We were losing sales.
We moved to an event-driven architecture with queued jobs. Game changer.
The Order Schema
Schema::create('orders', function (Blueprint $table) {
$table->id();
$table->string('order_number')->unique()->index();
$table->foreignId('user_id')->nullable()->constrained();
// Contact info (for guest checkouts)
$table->string('email');
$table->string('phone')->nullable();
// Billing address
$table->string('billing_name');
$table->string('billing_address');
$table->string('billing_city');
$table->string('billing_state');
$table->string('billing_zip');
$table->string('billing_country');
// Shipping address
$table->string('shipping_name');
$table->string('shipping_address');
$table->string('shipping_city');
$table->string('shipping_state');
$table->string('shipping_zip');
$table->string('shipping_country');
// Financial
$table->decimal('subtotal', 10, 2);
$table->decimal('tax', 10, 2);
$table->decimal('shipping', 10, 2);
$table->decimal('discount', 10, 2)->default(0);
$table->decimal('total', 10, 2);
// Status tracking
$table->enum('status', [
'pending',
'processing',
'shipped',
'delivered',
'cancelled',
'refunded'
])->default('pending')->index();
$table->enum('payment_status', [
'pending',
'paid',
'failed',
'refunded'
])->default('pending')->index();
// Payment details
$table->string('payment_method');
$table->string('payment_id')->nullable(); // Stripe charge ID, etc.
Unlock Premium Content
You've read 30% of this article
What's in the full article
- Complete step-by-step implementation guide
- Working code examples you can copy-paste
- Advanced techniques and pro tips
- Common mistakes to avoid
- Real-world examples and metrics
Don't have an account? Start your free trial
Join 10,000+ developers who love our premium content
Never Miss an Article
Get our best content delivered to your inbox weekly. No spam, unsubscribe anytime.
Comments (0)
Please log in to leave a comment.
Log In