Daniel Hartwell
Listen to Article
Loading...Building a Production-Grade E-Commerce Platform with Laravel 12, Stripe, and Kubernetes - Part 3: Advanced Features & Configuration
Read time: 22 minutes | Level: Advanced | Part 3 of 8
Table of Contents
- Introduction & What We're Building
- Real-Time Inventory Management with WebSockets
- Multi-Layer Caching Strategy
- Advanced Queue Processing & Dead Letter Handling
- Stripe Webhook Security & Idempotency
- Dynamic Pricing Engine
- Advanced Search with Elasticsearch Integration
- Common Pitfalls & Production Lessons
- What's Next
Introduction & What We're Building
In Parts 1 and 2, we established the foundation for our e-commerce platform with robust authentication, payment processing, and deployment infrastructure. Now we face the real-world challenges that separate hobby projects from production systems handling thousands of concurrent users.
This part tackles the complex features that break at scale: inventory race conditions when 500 people try to buy the last item simultaneously, webhook replay attacks that charge customers twice, cache invalidation patterns that prevent stale product data, and queue failures that lose critical order processing jobs.
We'll implement solutions proven in production environments, not theoretical patterns. Every code example includes error handling, logging, and monitoring because systems fail in production - the question is whether you've planned for it.
What you'll implement:
- WebSocket-driven real-time inventory updates that prevent overselling
- Three-tier caching (Redis + CDN + database) with smart invalidation
- Queue processing with automatic retries, exponential backoff, and dead letter queues
- Webhook signature verification and idempotency to prevent duplicate charges
- Dynamic pricing rules engine for promotions, bulk discounts, and time-based pricing
- Elasticsearch integration for sub-50ms product search across millions of SKUs
Prerequisites: You've completed Parts 1-2, have a running Kubernetes cluster, and understand Laravel's service container, event system, and Eloquent ORM.
Real-Time Inventory Management with WebSockets
The Problem: Race Conditions at Scale
When 500 customers simultaneously add the last 50 units of a product to their carts, naive implementations oversell by 200-300 units. Database-level transactions help but don't solve the UX problem: customers complete checkout only to discover their order failed.
The solution: WebSocket-driven real-time inventory broadcasting combined with optimistic locking at the database level.
Architecture Overview
┌─────────────┐ ┌──────────────┐ ┌─────────────┐
│ Browser │◄────────┤ Laravel │◄────────┤ Redis │
│ (Vue.js) │ WS │ Reverb │ PubSub │ (PubSub) │
└─────────────┘ └──────────────┘ └─────────────┘
│ ▲
│ │
▼ │
┌──────────────┐ │
│ Queue Job │────────────────┘
│ (Inventory) │ Publish Event
└──────────────┘
Step 1: Install and Configure Laravel Reverb
Laravel 12 ships with Reverb, a first-party WebSocket server written in PHP that integrates seamlessly with Broadcasting.
# Install Reverb
composer require laravel/reverb
# Publish configuration
php artisan reverb:install
# This creates config/reverb.php and updates broadcasting.php
Configuration (config/reverb.php):
<?php
return [
/*
|--------------------------------------------------------------------------
| Default Reverb Server
|--------------------------------------------------------------------------
*/
'default' => env('REVERB_SERVER', 'reverb'),
'servers' => [
'reverb' => [
'host' => env('REVERB_SERVER_HOST', '0.0.0.0'),
'port' => env('REVERB_SERVER_PORT', 8080),
'hostname' => env('REVERB_HOST', 'localhost'),
'options' => [
'tls' => [
// In production, use proper TLS certificates
'local_cert' => env('REVERB_TLS_CERT'),
'local_pk' => env('REVERB_TLS_KEY'),
'verify_peer' => env('REVERB_TLS_VERIFY_PEER', true),
],
],
'scaling' => [
'enabled' => env('REVERB_SCALING_ENABLED', true),
'channel' => env('REVERB_SCALING_CHANNEL', 'reverb'),
'server' => [
'url' => env('REDIS_URL'),
'host' => env('REDIS_HOST', '127.0.0.1'),
'port' => env('REDIS_PORT', 6379),
'password' => env('REDIS_PASSWORD'),
],
],
'pulse_ingest_interval' => env('REVERB_PULSE_INGEST_INTERVAL', 15),
'telescope_ingest_interval' => env('REVERB_TELESCOPE_INGEST_INTERVAL', 15),
],
],
'apps' => [
'apps' => [
[
'app_id' => env('REVERB_APP_ID'),
'app_key' => env('REVERB_APP_KEY'),
'app_secret' => env('REVERB_APP_SECRET'),
'options' => [
'host' => env('REVERB_HOST'),
'port' => env('REVERB_PORT', 443),
'scheme' => env('REVERB_SCHEME', 'https'),
'useTLS' => env('REVERB_SCHEME', 'https') === 'https',
],
'allowed_origins' => explode(',', env('REVERB_ALLOWED_ORIGINS', '*')),
'ping_interval' => env('REVERB_PING_INTERVAL', 30),
'max_message_size' => env('REVERB_MAX_MESSAGE_SIZE', 10000),
],
],
],
];
Environment variables (.env):
BROADCAST_CONNECTION=reverb
REVERB_APP_ID=ecommerce-platform
REVERB_APP_KEY=vwxyz123456789abcdef
REVERB_APP_SECRET=abcdef987654321vwxyz
REVERB_HOST=reverb.yourdomain.com
REVERB_PORT=443
REVERB_SCHEME=https
REVERB_SERVER_HOST=0.0.0.0
REVERB_SERVER_PORT=8080
# Enable Redis-backed horizontal scaling
REVERB_SCALING_ENABLED=true
REVERB_SCALING_CHANNEL=reverb-scaling
# Production: Use actual certificates
REVERB_TLS_CERT=/etc/ssl/certs/reverb.crt
REVERB_TLS_KEY=/etc/ssl/private/reverb.key
REVERB_TLS_VERIFY_PEER=true
# Allow specific origins (NEVER use * in production)
REVERB_ALLOWED_ORIGINS=https://yourdomain.com,https://www.yourdomain.com
Step 2: Create Broadcasting Event with Optimistic Locking
Migration for optimistic locking (database/migrations/xxxx_add_version_to_products.php):
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Add version column for optimistic locking.
*
* This prevents race conditions when multiple processes
* try to update inventory simultaneously.
*/
public function up(): void
{
Schema::table('products', function (Blueprint $table) {
$table->unsignedInteger('version')->default(0)->after('stock_quantity');
$table->index(['id', 'version']); // Composite index for WHERE clauses
});
}
public function down(): void
{
Schema::table('products', function (Blueprint $table) {
$table->dropColumn('version');
});
}
};
Event (app/Events/InventoryUpdated.php):
<?php
namespace App\Events;
use App\Models\Product;
use Illuminate\Broadcasting\Channel;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
/**
* Broadcast inventory changes in real-time to all connected clients.
*
* ShouldBroadcastNow ensures immediate broadcasting without queuing,
* critical for inventory updates to prevent overselling.
*/
class InventoryUpdated implements ShouldBroadcastNow
{
use Dispatchable, InteractsWithSockets, SerializesModels;
public function __construct(
public Product $product,
public int $previousQuantity,
public string $changeReason
) {}
/**
* Get the channels the event should broadcast on.
*
* Using a public channel since inventory is public data.
* For sensitive inventory (e.g., wholesale pricing), use PrivateChannel.
*/
public function broadcastOn(): Channel
{
return new Channel('inventory');
}
/**
* Data sent to WebSocket clients.
*
* Keep payload small - only send what clients need.
* Avoid sending full product object with relationships.
*/
public function broadcastWith(): array
{
return [
'product_id' => $this->product->id,
'sku' => $this->product->sku,
'stock_quantity' => $this->product->stock_quantity,
'previous_quantity' => $this->previousQuantity,
'is_in_stock' => $this->product->stock_quantity > 0,
'is_low_stock' => $this->product->stock_quantity > 0
&& $this->product->stock_quantity <= 10,
'change_reason' => $this->changeReason,
'timestamp' => now()->toIso8601String(),
];
}
/**
* Event name sent to clients.
*
* Clients listen for 'inventory.updated' on the 'inventory' channel.
*/
public function broadcastAs(): string
{
return 'inventory.updated';
}
}
Step 3: Inventory Service with Optimistic Locking
Service (app/Services/InventoryService.php):
<?php
namespace App\Services;
use App\Events\InventoryUpdated;
use App\Models\Product;
use App\Exceptions\InsufficientStockException;
use App\Exceptions\ConcurrentUpdateException;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Cache;
class InventoryService
{
/**
* Reserve inventory with optimistic locking.
*
* This method prevents overselling by using database-level
* version checking. If 10 requests try to reserve the last
* 5 units, only 5 will succeed.
*
* @throws InsufficientStockException
* @throws ConcurrentUpdateException
*/
public function reserve(Product $product, int $quantity): bool
{
// Validate request
if ($quantity <= 0) {
throw new \InvalidArgumentException('Quantity must be positive');
}
$maxRetries = 3;
$attempt = 0;
while ($attempt < $maxRetries) {
$attempt++;
try {
return DB::transaction(function () use ($product, $quantity) {
// Lock the row for update within transaction
$currentProduct = Product::where('id', $product->id)
->lockForUpdate()
->first();
// Check stock availability
if ($currentProduct->stock_quantity < $quantity) {
Log::warning('Insufficient stock', [
'product_id' => $product->id,
'requested' => $quantity,
'available' => $currentProduct->stock_quantity,
]);
throw new InsufficientStockException(
"Only {$currentProduct->stock_quantity} units available"
);
}
// Optimistic locking: update only if version matches
$updated = DB::table('products')
->where('id', $currentProduct->id)
->where('version', $currentProduct->version)
->update([
'stock_quantity' => $currentProduct->stock_quantity - $quantity,
'version' => $currentProduct->version + 1,
'updated_at' => now(),
]);
// If $updated is 0, another process modified the row
if ($updated === 0) {
throw new ConcurrentUpdateException(
'Product was modified by another process'
);
}
// Refresh model to get new version
$currentProduct->refresh();
// Invalidate cache
Cache::tags(['products', "product:{$currentProduct->id}"])
->flush();
// Broadcast inventory change in real-time
broadcast(new InventoryUpdated(
product: $currentProduct,
previousQuantity: $currentProduct->stock_quantity + $quantity,
changeReason: 'reservation'
));
Log::info('Inventory reserved successfully', [
'product_id' => $currentProduct->id,
'quantity' => $quantity,
'remaining' => $currentProduct->stock_quantity,
'version' => $currentProduct->version,
]);
return true;
});
} catch (ConcurrentUpdateException $e) {
// Retry on concurrent modification
if ($attempt >= $maxRetries) {
Log::error('Max retries exceeded for inventory reservation', [
'product_id' => $product->id,
'quantity' => $quantity,
'attempts' => $attempt,
]);
throw $e;
}
// Exponential backoff: 10ms, 20ms, 40ms
usleep(10000 * pow(2, $attempt - 1));
// Refresh product data before retry
$product->refresh();
continue;
}
}
return false;
}
/**
* Release reserved inventory (e.g., when order expires).
*
* Uses same optimistic locking pattern for consistency.
*/
public function release(Product $product, int $quantity): bool
{
return DB::transaction(function () use ($product, $quantity) {
$currentProduct = Product::where('id', $product->id)
->lockForUpdate()
->first();
$updated = DB::table('products')
->where('id', $currentProduct->id)
->where('version', $currentProduct->version)
->update([
'stock_quantity' => $currentProduct->stock_quantity + $quantity,
'version' => $currentProduct->version + 1,
'updated_at' => now(),
]);
if ($updated === 0) {
throw new ConcurrentUpdateException(
'Product was modified during release'
);
}
$currentProduct->refresh();
Cache::tags(['products', "product:{$currentProduct->id}"])->flush();
broadcast(new InventoryUpdated(
product: $currentProduct,
previousQuantity: $currentProduct->stock_quantity - $quantity,
changeReason: 'release'
));
return true;
});
}
}
Step 4: Frontend WebSocket Integration
JavaScript client (resources/js/inventory-listener.js):
import Echo from 'laravel-echo';
import Pusher from 'pusher-js';
window.Pusher = Pusher;
/**
* Initialize Laravel Echo with Reverb.
*
* In production, use wss:// (secure WebSocket) and proper auth.
*/
window.Echo = new Echo({
broadcaster: 'reverb',
key: import.meta.env.VITE_REVERB_APP_KEY,
wsHost: import.meta.env.VITE_REVERB_HOST,
wsPort: import.meta.env.VITE_REVERB_PORT ?? 443,
wssPort: import.meta.env.VITE_REVERB_PORT ?? 443,
forceTLS: (import.meta.env.VITE_REVERB_SCHEME ?? 'https') === 'https',
enabledTransports: ['ws', 'wss'],
// Critical: implement reconnection logic
enableLogging: import.meta.env.DEV,
disableStats: import.meta.env.PROD,
// Reconnect after 1s, 2s, 4s, 8s, max 30s
reconnectionDelayGrowthFactor: 2,
reconnectionDelayMax: 30000,
reconnectionDelay: 1000,
});
/**
* Real-time inventory listener.
*
* Updates UI immediately when inventory changes server-side.
* Prevents users from attempting to purchase out-of-stock items.
*/
class InventoryListener {
constructor() {
this.channel = null;
this.listeners = new Map();
this.connectionState = 'disconnected';
}
connect() {
this.channel = window.Echo.channel('inventory');
// Connection state monitoring
this.channel.on('pusher:subscription_succeeded', () => {
this.connectionState = 'connected';
console.log('✅ Connected to inventory channel');
});
this.channel.on('pusher:subscription_error', (error) => {
this.connectionState = 'error';
console.error('❌ Inventory subscription error:', error);
// Report to error tracking (e.g., Sentry)
if (window.Sentry) {
window.Sentry.captureException(error);
}
});
// Listen for inventory updates
this.channel.listen('.inventory.updated', (event) => {
console.log('📦 Inventory updated:', event);
// Call all registered listeners for this product
const productListeners = this.listeners.get(event.product_id) || [];
productListeners.forEach(callback => {
try {
callback(event);
} catch (error) {
console.error('Error in inventory listener callback:', error);
}
});
// Update global inventory cache
this.updateInventoryCache(event);
});
return this;
}
/**
* Register callback for specific product.
*
* @param {number} productId
* @param {Function} callback
*/
onProductUpdate(productId, callback) {
if (!this.listeners.has(productId)) {
this.listeners.set(productId, []);
}
this.listeners.get(productId).push(callback);
}
/**
* Update local storage cache to prevent stale data.
*/
updateInventoryCache(event) {
const cacheKey = `inventory_${event.product_id}`;
const cacheData = {
stock_quantity: event.stock_quantity,
is_in_stock: event.is_in_stock,
is_low_stock: event.is_low_stock,
last_updated: event.timestamp,
};
localStorage.setItem(cacheKey, JSON.stringify(cacheData));
}
disconnect() {
if (this.channel) {
window.Echo.leaveChannel('inventory');
this.channel = null;
this.connectionState = 'disconnected';
}
}
}
// Initialize and export singleton
const inventoryListener = new InventoryListener();
export default inventoryListener;
// Auto-connect on page load
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => inventoryListener.connect());
} else {
inventoryListener.connect();
}
Vue component example (resources/js/components/ProductCard.vue):
<template>
<div class="product-card" :class="{ 'out-of-stock': !isInStock }">
<img :src="product.image_url" :alt="product.name" />
<h3>{{ product.name }}</h3>
<p class="price">${{ product.price }}</p>
<div v-if="isInStock" class="stock-info">
<span v-if="isLowStock" class="badge badge-warning">
Only {{ stockQuantity }} left!
</span>
<button
@click="addToCart"
:disabled="isAddingToCart"
class="btn btn-primary"
>
{{ isAddingToCart ? 'Adding...' : 'Add to Cart' }}
</button>
</div>
<div v-else class="stock-info">
<span class="badge badge-danger">Out of Stock</span>
<button @click="notifyWhenAvailable" class="btn btn-secondary">
Notify Me
</button>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, onUnmounted, computed } from 'vue';
import inventoryListener from '../inventory-listener';
const props = defineProps({
product: {
type: Object,
required: true,
},
});
const stockQuantity = ref(props.product.stock_quantity);
const isAddingToCart = ref(false);
const isInStock = computed(() => stockQuantity.value > 0);
const isLowStock = computed(() => stockQuantity.value > 0 && stockQuantity.value <= 10);
/**
* Real-time inventory update handler.
*
* When inventory changes server-side, this updates the UI
* immediately without page refresh.
*/
const handleInventoryUpdate = (event) => {
console.log(`Product ${props.product.id} inventory changed: ${event.stock_quantity}`);
stockQuantity.value = event.stock_quantity;
// Show toast notification if product just went out of stock
if (!event.is_in_stock && stockQuantity.value === 0) {
showToast('This item just sold out', 'warning');
}
};
const addToCart = async () => {
isAddingToCart.value = true;
try {
const response = await fetch('/api/cart/add', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content,
},
body: JSON.stringify({
product_id: props.product.id,
quantity: 1,
}),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.message || 'Failed to add to cart');
}
showToast('Added to cart!', 'success');
} catch (error) {
console.error('Add to cart error:', error);
showToast(error.message, 'error');
} finally {
isAddingToCart.value = false;
}
};
// Register listener on mount, cleanup on unmount
onMounted(() => {
inventoryListener.onProductUpdate(props.product.id, handleInventoryUpdate);
});
onUnmounted(() => {
// In a production app, you'd want to remove specific listeners
// to prevent memory leaks
});
</script>
Step 5: Deploy Reverb in Kubernetes
Kubernetes deployment (k8s/reverb-deployment.yaml):
apiVersion: apps/v1
kind: Deployment
metadata:
name: reverb
namespace: ecommerce
spec:
replicas: 3 # Horizontal scaling for high availability
selector:
matchLabels:
app: reverb
template:
metadata:
labels:
app: reverb
spec:
containers:
- name: reverb
image: ghcr.io/ibekzod/laravel-ecommerce-platform:latest
command: ["php", "artisan", "reverb:start"]
ports:
- containerPort: 8080
name: websocket
env:
- name: REVERB_SERVER_HOST
value: "0.0.0.0"
- name: REVERB_SERVER_PORT
value: "8080"
- name: REVERB_SCALING_ENABLED
value: "true"
- name: REDIS_HOST
value: "redis-service"
- name: REDIS_PORT
value: "6379"
resources:
requests:
memory: "256Mi"
cpu: "250m"
limits:
memory: "512Mi"
cpu: "500m"
livenessProbe:
httpGet:
path: /health
port: 8080
initialDelaySeconds: 10
periodSeconds: 10
readinessProbe:
httpGet:
path: /health
port: 8080
initialDelaySeconds: 5
periodSeconds: 5
---
apiVersion: v1
kind: Service
metadata:
name: reverb-service
namespace: ecommerce
spec:
type: ClusterIP
ports:
- port: 8080
targetPort: 8080
protocol: TCP
name: websocket
selector:
app: reverb
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: reverb-ingress
namespace: ecommerce
annotations:
cert-manager.io/cluster-issuer: "letsencrypt-prod"
nginx.ingress.kubernetes.io/proxy-read-timeout: "3600"
nginx.ingress.kubernetes.io/proxy-send-timeout: "3600"
nginx.ingress.kubernetes.io/websocket-services: "reverb-service"
spec:
ingressClassName: nginx
tls:
- hosts:
- reverb.yourdomain.com
secretName: reverb-tls
rules:
- host: reverb.yourdomain.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: reverb-service
port:
number: 8080
Apply the configuration:
kubectl apply -f k8s/reverb-deployment.yaml
# Verify deployment
kubectl get pods -n ecommerce | grep reverb
# Check logs
kubectl logs -f deployment/reverb -n ecommerce
# Expected output:
# [2024-01-15 10:30:45] Starting Reverb server...
# [2024-01-15 10:30:45] Server started on 0.0.0.0:8080
# [2024-01-15 10:30:45] Scaling enabled with Redis
Multi-Layer Caching Strategy
The Problem: Database Queries Under Load
A product page with 10,000 visits/hour executing 5 database queries each generates 50,000 queries/hour. Add category listings, search, and related products - you're at 200,000+ queries/hour. PostgreSQL handles this, but response times degrade from 50ms to 500ms+ under load.
The solution: Three-tier caching with granular invalidation.
Caching Architecture
┌──────────────────────────────────────────────────────────────┐
│ Request Flow │
└──────────────────────────────────────────────────────────────┘
│
▼
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ CDN Cache │───────▶│ Redis Cache │───────▶│ Database │
│ (CloudFlare)│ │ (L1 + L2) │ │ (PostgreSQL) │
│ Static: 1hr │ │ Hot: 5min │ │ Source of │
│ API: 30s │ │ Warm: 1hr │ │ Truth │
└──────────────┘ └──────────────┘ └──────────────┘
TTL-based Tag-based Optimistic
Invalidation Invalidation Locking
Step 1: Repository Pattern with Multi-Tier Caching
Repository (app/Repositories/ProductRepository.php):
<?php
namespace App\Repositories;
use App\Models\Product;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Database\Eloquent\Collection;
class ProductRepository
{
/**
* Cache configuration.
*
* L1 (hot): Frequently accessed data, 5-minute TTL
* L2 (warm): Less frequent access, 1-hour TTL
* L3 (cold): Rarely changes, 24-hour TTL
*/
private const CACHE_TTL_HOT = 300; // 5 minutes
private const CACHE_TTL_WARM = 3600; // 1 hour
private const CACHE_TTL_COLD = 86400; // 24 hours
/**
* Get product by ID with multi-tier caching.
*
* Cache hierarchy:
* 1. Check L1 cache (5min, for hot products)
* 2. Check L2 cache (1hr, for warm products)
* 3. Query database and cache result
*
* Uses cache tags for granular invalidation.
*/
public function findById(int $id, array $with = []): ?Product
{
$cacheKey = "product:{$id}:with:" . md5(serialize($with));
// Try L1 cache first (hot data)
$product = Cache::tags(['products', "product:{$id}"])
->remember(
$cacheKey,
self::CACHE_TTL_HOT,
function () use ($id, $with, $cacheKey) {
Log::debug('L1 cache miss', ['key' => $cacheKey]);
// Try L2 cache (warm data)
return Cache::tags(['products', "product:{$id}"])
->remember(
"{$cacheKey}:l2",
self::CACHE_TTL_WARM,
function () use ($id, $with, $cacheKey) {
Log::debug('L2 cache miss, querying database', [
'key' => $cacheKey,
]);
// Query database
$query = Product::query();
if (!empty($with)) {
$query->with($with);
}
return $query->find($id);
}
);
}
);
return $product;
}
/**
* Get products by category with pagination caching.
*
* Caching paginated results is tricky:
* - Cache each page separately
* - Invalidate all pages when category products change
* - Use cursor-based pagination for better cache hit rates
*/
public function findByCategory(
int $categoryId,
int $page = 1,
int $perPage = 20
): Collection {
$cacheKey = "category:{$categoryId}:page:{$page}:per_page:{$perPage}";
return Cache::tags(['products', "category:{$categoryId}"])
->remember(
$cacheKey,
self::CACHE_TTL_WARM,
function () use ($categoryId, $page, $perPage) {
return Product::where('category_id', $categoryId)
->where('is_active', true)
->orderBy('created_at', 'desc')
->skip(($page - 1) * $perPage)
->take($perPage)
->get();
}
);
}
/**
* Get featured products with aggressive caching.
*
* Featured products change infrequently (manually curated),
* so we can cache them for 24 hours.
*/
public function getFeatured(int $limit = 10): Collection
{
return Cache::tags(['products', 'featured'])
->remember(
"products:featured:{$limit}",
self::CACHE_TTL_COLD,
function () use ($limit) {
return Product::where('is_featured', true)
->where('is_active', true)
->where('stock_quantity', '>', 0)
->orderBy('featured_order', 'asc')
->limit($limit)
->get();
}
);
}
/**
* Search products with query result caching.
*
* Search queries are expensive (LIKE %term% or full-text search).
* Cache common search terms aggressively.
*/
public function search(string $query, int $limit = 20): Collection
{
// Normalize query for cache key
$normalizedQuery = strtolower(trim($query));
$cacheKey = "search:" . md5($normalizedQuery) . ":{$limit}";
return Cache::tags(['products', 'search'])
->remember(
$cacheKey,
self::CACHE_TTL_WARM,
function () use ($query, $limit) {
// Use full-text search if available
return Product::where('is_active', true)
->where(function ($q) use ($query) {
$q->where('name', 'ILIKE', "%{$query}%")
->orWhere('description', 'ILIKE', "%{$query}%")
->orWhere('sku', 'ILIKE', "%{$query}%");
})
->limit($limit)
->get();
}
);
}
/**
* Invalidate all caches for a specific product.
*
* Called when product is updated, inventory changes, etc.
* Uses cache tags to flush all related caches atomically.
*/
public function invalidateProductCache(int $productId): void
{
$tags = ['products', "product:{$productId}"];
// Also invalidate category cache if product has category
$product = Product::find($productId);
if ($product && $product->category_id) {
$tags[] = "category:{$product->category_id}";
}
Cache::tags($tags)->flush();
Log::info('Product cache invalidated', [
'product_id' => $productId,
'tags' => $tags,
]);
}
/**
* Invalidate search cache.
*
* Called less frequently - only when bulk product changes occur
* (imports, mass updates, etc.).
*/
public function invalidateSearchCache(): void
{
Cache::tags(['products', 'search'])->flush();
Log::info('Search cache invalidated');
}
/**
* Warm cache for hot products.
*
* Pre-populate cache during off-peak hours or after deployments.
* Run via scheduled command: php artisan cache:warm-products
*/
public function warmCache(array $productIds): void
{
$startTime = microtime(true);
$warmed = 0;
foreach ($productIds as $productId) {
try {
$this->findById($productId);
$warmed++;
} catch (\Exception $e) {
Log::warning('Failed to warm cache for product', [
'product_id' => $productId,
'error' => $e->getMessage(),
]);
}
}
$duration = round((microtime(true) - $startTime) * 1000, 2);
Log::info('Cache warming completed', [
'products_warmed' => $warmed,
'duration_ms' => $duration,
]);
}
}
Step 2: CDN Integration for Static Assets
CDN configuration (config/cdn.php):
<?php
return [
/*
|--------------------------------------------------------------------------
| CDN Configuration
|--------------------------------------------------------------------------
|
| Configure CloudFlare or alternative CDN for static asset delivery.
| Images, CSS, JS served from CDN reduce origin server load.
*/
'enabled' => env('CDN_ENABLED', true),
'url' => env('CDN_URL', 'https://cdn.yourdomain.com'),
'cloudflare' => [
'zone_id' => env('CLOUDFLARE_ZONE_ID'),
'api_token' => env('CLOUDFLARE_API_TOKEN'),
// Cache-Control headers for different asset types
'cache_control' => [
'images' => 'public, max-age=31536000, immutable', // 1 year
'css' => 'public, max-age=31536000, immutable', // 1 year
'js' => 'public, max-age=31536000, immutable', // 1 year
'fonts' => 'public, max-age=31536000, immutable', // 1 year
'api' => 'public, max-age=30, s-maxage=30', // 30 seconds
],
],
];
Helper function for CDN URLs (app/Helpers/cdn.php):
<?php
if (!function_exists('cdn_asset')) {
/**
* Generate CDN URL for assets.
*
* Automatically switches between local and CDN URLs based on config.
* Adds cache-busting query string based on file modification time.
*
* @param string $path Asset path relative to public directory
* @return string Full URL to asset (CDN or local)
*/
function cdn_asset(string $path): string
{
// Strip leading slash
$path = ltrim($path, '/');
// In local development, serve from app
if (!config('cdn.enabled') || app()->environment('local')) {
return asset($path);
}
// Add cache-busting query parameter based on file mtime
$publicPath = public_path($path);
$version = file_exists($publicPath)
? '?v=' . filemtime($publicPath)
: '';
return config('cdn.url') . '/' . $path . $version;
}
}
if (!function_exists('cdn_image')) {
/**
* Generate CDN URL for user-uploaded images.
*
* Uses image transformations (resize, format conversion) via CDN.
* Example: cdn_image('products/abc123.jpg', ['width' => 400])
*
* @param string $path Image path relative to storage
* @param array $options Transformation options
* @return string CDN URL with transformations
*/
function cdn_image(string $path, array $options = []): string
{
if (!config('cdn.enabled')) {
return Storage::url($path);
}
$cdnUrl = config('cdn.url') . '/images/' . ltrim($path, '/');
// Add CloudFlare image transformations
if (!empty($options)) {
$transformations = [];
if (isset($options['width'])) {
$transformations[] = "width={$options['width']}";
}
if (isset($options['height'])) {
$transformations[] = "height={$options['height']}";
}
if (isset($options['format'])) {
$transformations[] = "format={$options['format']}";
}
if (isset($options['quality'])) {
$transformations[] = "quality={$options['quality']}";
}
if (!empty($transformations)) {
$cdnUrl .= '?' . implode('&', $transformations);
}
}
return $cdnUrl;
}
}
Step 3: Programmatic Cache Invalidation
Service for CloudFlare cache purging (app/Services/CdnService.php):
<?php
namespace App\Services;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class CdnService
{
private string $zoneId;
private string $apiToken;
private string $baseUrl = 'https://api.cloudflare.com/client/v4';
public function __construct()
{
$this->zoneId = config('cdn.cloudflare.zone_id');
$this->apiToken = config('cdn.cloudflare.api_token');
}
/**
* Purge specific URLs from CloudFlare cache.
*
* Use after product updates, image uploads, or content changes.
* CloudFlare allows 30 purge operations per minute.
*
* @param array $urls Array of full URLs to purge
* @return bool Success status
*/
public function purgeUrls(array $urls): bool
{
if (empty($urls)) {
return true;
}
try {
$response = Http::withHeaders([
'Authorization' => "Bearer {$this->apiToken}",
'Content-Type' => 'application/json',
])->post("{$this->baseUrl}/zones/{$this->zoneId}/purge_cache", [
'files' => $urls,
]);
if ($response->successful()) {
Log::info('CDN cache purged successfully', [
'urls' => $urls,
'count' => count($urls),
]);
return true;
}
Log::error('CDN cache purge failed', [
'urls' => $urls,
'status' => $response->status(),
'response' => $response->json(),
]);
return false;
} catch (\Exception $e) {
Log::error('CDN cache purge exception', [
'urls' => $urls,
'error' => $e->getMessage(),
]);
return false;
}
}
/**
* Purge all cached content (use sparingly).
*
* Only use during major deployments or emergency fixes.
* Can cause origin server load spike.
*/
public function purgeAll(): bool
{
try {
$response = Http::withHeaders([
'Authorization' => "Bearer {$this->apiToken}",
'Content-Type' => 'application/json',
])->post("{$this->baseUrl}/zones/{$this->zoneId}/purge_cache", [
'purge_everything' => true,
]);
if ($response->successful()) {
Log::warning('CDN full cache purge executed');
return true;
}
return false;
} catch (\Exception $e) {
Log::error('CDN full cache purge failed', [
'error' => $e->getMessage(),
]);
return false;
}
}
/**
* Purge cache for specific product.
*
* Purges product page, images, and API endpoints.
*/
public function purgeProduct(int $productId): bool
{
$urls = [
config('app.url') . "/products/{$productId}",
config('app.url') . "/api/products/{$productId}",
// Add product image URLs if predictable
];
return $this->purgeUrls($urls);
}
}
Observer for automatic cache invalidation (app/Observers/ProductObserver.php):
<?php
namespace App\Observers;
use App\Models\Product;
use App\Repositories\ProductRepository;
use App\Services\CdnService;
class ProductObserver
{
public function __construct(
private ProductRepository $productRepository,
private CdnService $cdnService
) {}
/**
* Handle product updated event.
*
* Invalidates all caches related to this product.
*/
public function updated(Product $product): void
{
// Invalidate Redis cache
$this->productRepository->invalidateProductCache($product->id);
// Invalidate CDN cache
$this->cdnService->purgeProduct($product->id);
}
/**
* Handle product deleted event.
*/
public function deleted(Product $product): void
{
$this->productRepository->invalidateProductCache($product->id);
$this->cdnService->purgeProduct($product->id);
}
}
Advanced Queue Processing & Dead Letter Handling
The Problem: Lost Jobs in Production
Queue jobs fail for dozens of reasons: API timeouts, database deadlocks, memory exhaustion, third-party service outages. Default Laravel retry logic helps, but eventually jobs fail permanently and disappear into the void. Critical order processing jobs can be lost.
The solution: Dead letter queue pattern with automatic retry escalation and manual intervention dashboard.
Architecture
┌─────────────┐ ┌──────────────┐ ┌──────────────┐
│ Primary │ Fail │ Retry │ Fail │ Dead Letter │
│ Queue │────────▶│ Queue │────────▶│ Queue │
│ │ │ (3 attempts) │ │ (Manual) │
└─────────────┘ └──────────────┘ └──────────────┘
│ │ │
│ Success │ Success │
▼ ▼ ▼
┌─────────────────────────────────────────────────────────────┐
│ Job Completed │
└─────────────────────────────────────────────────────────────┘
Step 1: Configure Multiple Queues
Queue configuration (config/queue.php additions):
'connections' => [
'redis' => [
'driver' => 'redis',
'connection' => env('REDIS_QUEUE_CONNECTION', 'default'),
'queue' => env('REDIS_QUEUE', 'default'),
'retry_after' => 90,
'block_for' => null,
'after_commit' => false,
// Configure multiple queues with priorities
'queues' => [
'critical' => [
'retry_after' => 90,
'max_tries' => 5,
],
'default' => [
'retry_after' => 90,
'max_tries' => 3,
],
'low' => [
'retry_after' => 300,
'max_tries' => 2,
],
],
],
],
// Dead letter queue configuration
'failed' => [
'driver' => env('QUEUE_FAILED_DRIVER', 'database-uuids'),
'database' => env('DB_CONNECTION', 'pgsql'),
'table' => 'failed_jobs',
],
Step 2: Advanced Job Implementation
Order processing job with comprehensive error handling (app/Jobs/ProcessOrder.php):
<?php
namespace App\Jobs;
use App\Models\Order;
use App\Services\InventoryService;
use App\Services\PaymentService;
use App\Services\NotificationService;
use App\Exceptions\PaymentFailedException;
use App\Exceptions\InsufficientStockException;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Queue\Middleware\RateLimited;
use Illuminate\Queue\Middleware\WithoutOverlapping;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\DB;
use Throwable;
/**
* Process order with comprehensive error handling and retry logic.
*
* This job handles the critical path: charge payment, reserve inventory,
* send confirmation. Failures here directly impact revenue.
*/
class ProcessOrder implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
/**
* Maximum number of attempts before moving to dead letter queue.
*
* Attempts run at: 0s, 10s, 60s, 300s (5min)
*/
public int $tries = 4;
/**
* Timeout in seconds. Order processing should complete in <30s.
* Longer suggests API issues or database contention.
*/
public int $timeout = 30;
/**
* Exponential backoff between retries.
* [10, 60, 300] = 10s, 1min, 5min
*/
public function backoff(): array
{
return [10, 60, 300];
}
/**
* Tags for queue monitoring and filtering.
*/
public function tags(): array
{
return ['order', "order:{$this->order->id}", "user:{$this->order->user_id}"];
}
public function __construct(
public Order $order
) {
// Queue on 'critical' for orders, 'default' for other jobs
$this->onQueue('critical');
}
/**
* Middleware to prevent concurrent processing of same order.
*/
public function middleware(): array
{
return [
// Prevent multiple workers from processing same order
new WithoutOverlapping($this->order->id),
// Rate limit: max 100 order processing jobs per minute
new RateLimited('order-processing'),
];
}
/**
* Execute the job.
*/
public function handle(
InventoryService $inventoryService,
PaymentService $paymentService,
NotificationService $notificationService
): void {
Log::info('Processing order', [
'order_id' => $this->order->id,
'attempt' => $this->attempts(),
]);
// Start database transaction
DB::beginTransaction();
try {
// Step 1: Charge payment
$paymentResult = $paymentService->charge(
amount: $this->order->total_amount,
currency: $this->order->currency,
paymentMethodId: $this->order->payment_method_id,
metadata: [
'order_id' => $this->order->id,
'customer_email' => $this->order->user->email,
]
);
if (!$paymentResult->successful) {
throw new PaymentFailedException(
"Payment failed: {$paymentResult->error}"
);
}
// Step 2: Reserve inventory for each order item
foreach ($this->order->items as $item) {
$inventoryService->reserve(
product: $item->product,
quantity: $item->quantity
);
}
// Step 3: Update order status
$this->order->update([
'status' => 'processing',
'payment_id' => $paymentResult->transactionId,
'processed_at' => now(),
]);
DB::commit();
// Step 4: Send confirmation (outside transaction)
$notificationService->sendOrderConfirmation($this->order);
Log::info('Order processed successfully', [
'order_id' => $this->order->id,
'payment_id' => $paymentResult->transactionId,
]);
} catch (PaymentFailedException $e) {
DB::rollBack();
// Payment failures are usually permanent (card declined, etc.)
// Don't retry, move directly to manual review
$this->fail($e);
Log::error('Order payment failed', [
'order_id' => $this->order->id,
'error' => $e->getMessage(),
]);
// Notify customer
$notificationService->sendPaymentFailedNotification(
$this->order,
$e->getMessage()
);
} catch (InsufficientStockException $e) {
DB::rollBack();
// Stock issues might resolve (returns, restocks)
// Retry with backoff
if ($this->attempts() >= $this->tries) {
$this->fail($e);
$notificationService->sendStockIssueNotification($this->order);
} else {
throw $e; // Retry
}
Log::warning('Order insufficient stock', [
'order_id' => $this->order->id,
'attempt' => $this->attempts(),
'error' => $e->getMessage(),
]);
} catch (Throwable $e) {
DB::rollBack();
// Unexpected errors: database deadlocks, API timeouts, etc.
// Retry with exponential backoff
Log::error('Order processing error', [
'order_id' => $this->order->id,
'attempt' => $this->attempts(),
'error' => $e->getMessage(),
'trace' => $e->getTraceAsString(),
]);
if ($this->attempts() >= $this->tries) {
$this->fail($e);
// Alert ops team for manual intervention
$notificationService->alertOpsTeam(
'Order processing failed after retries',
[
'order_id' => $this->order->id,
'error' => $e->getMessage(),
]
);
} else {
throw $e; // Retry
}
}
}
/**
* Handle job failure.
*
* Called when max attempts exceeded or fail() explicitly called.
*/
public function failed(Throwable $exception): void
{
Log::critical('Order job failed permanently', [
'order_id' => $this->order->id,
'exception' => $exception->getMessage(),
]);
// Mark order as failed
$this->order->update([
'status' => 'failed',
'failure_reason' => $exception->getMessage(),
'failed_at' => now(),
]);
// Create alert for manual review
DB::table('failed_order_alerts')->insert([
'order_id' => $this->order->id,
'exception' => get_class($exception),
'message' => $exception->getMessage(),
'payload' => json_encode([
'order' => $this->order->toArray(),
'trace' => $exception->getTraceAsString(),
]),
'created_at' => now(),
]);
}
}
Step 3: Dead Letter Queue Management
Migration for failed orders tracking:
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('failed_order_alerts', function (Blueprint $table) {
$table->id();
$table->foreignId('order_id')->constrained()->onDelete('cascade');
$table->string('exception');
$table->text('message');
$table->json('payload');
$table->enum('status', ['pending', 'investigating', 'resolved', 'ignored'])
->default('pending');
$table->foreignId('assigned_to')->nullable()
->constrained('users')->nullOnDelete();
$table->text('resolution_notes')->nullable();
$table->timestamp('resolved_at')->nullable();
$table->timestamps();
$table->index(['status', 'created_at']);
});
}
public function down(): void
{
Schema::dropIfExists('failed_order_alerts');
}
};
Admin controller for dead letter queue management (app/Http/Controllers/Admin/FailedOrderController.php):
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Jobs\ProcessOrder;
use App\Models\Order;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
class FailedOrderController extends Controller
{
/**
* Display failed orders dashboard.
*/
public function index(Request $request)
{
$query = DB::table('failed_order_alerts')
->join('orders', 'failed_order_alerts.order_id', '=', 'orders.id')
->join('users', 'orders.user_id', '=', 'users.id')
->select([
'failed_order_alerts.*',
'orders.total_amount',
'orders.currency',
'users.email as customer_email',
])
->orderBy('failed_order_alerts.created_at', 'desc');
// Filter by status
if ($request->has('status')) {
$query->where('failed_order_alerts.status', $request->status);
}
$failedOrders = $query->paginate(50);
return view('admin.failed-orders.index', compact('failedOrders'));
}
/**
* Show details for specific failed order.
*/
public function show(int $alertId)
{
$alert = DB::table('failed_order_alerts')
->where('id', $alertId)
->first();
if (!$alert) {
abort(404);
}
$order = Order::with(['user', 'items.product'])->find($alert->order_id);
$payload = json_decode($alert->payload, true);
return view('admin.failed-orders.show', compact('alert', 'order', 'payload'));
}
/**
* Manually retry failed order.
*
* Admin can review and retry after fixing underlying issue
* (restocking product, updating payment method, etc.).
*/
public function retry(int $alertId)
{
$alert = DB::table('failed_order_alerts')->find($alertId);
if (!$alert || $alert->status === 'resolved') {
return back()->with('error', 'Cannot retry this order');
}
$order = Order::find($alert->order_id);
// Update alert status
DB::table('failed_order_alerts')
->where('id', $alertId)
->update([
'status' => 'investigating',
'assigned_to' => auth()->id(),
'updated_at' => now(),
]);
// Dispatch job again
ProcessOrder::dispatch($order);
Log::info('Failed order manually retried', [
'alert_id' => $alertId,
'order_id' => $order->id,
'admin_user_id' => auth()->id(),
]);
return back()->with('success', 'Order retry job dispatched');
}
/**
* Mark alert as resolved.
*/
public function resolve(Request $request, int $alertId)
{
$request->validate([
'resolution_notes' => 'required|string|min:10',
]);
DB::table('failed_order_alerts')
->where('id', $alertId)
->update([
'status' => 'resolved',
'resolution_notes' => $request->resolution_notes,
'resolved_at' => now(),
'updated_at' => now(),
]);
return back()->with('success', 'Alert marked as resolved');
}
}
Step 4: Queue Worker Configuration
Supervisor configuration for queue workers (/etc/supervisor/conf.d/laravel-worker.conf):
[program:laravel-worker-critical]
process_name=%(program_name)s_%(process_num)02d
command=php /var/www/html/artisan queue:work redis --queue=critical --sleep=3 --tries=4 --max-time=3600 --memory=512
autostart=true
autorestart=true
stopasgroup=true
killasgroup=true
user=www-data
numprocs=4
redirect_stderr=true
stdout_logfile=/var/www/html/storage/logs/worker-critical.log
stopwaitsecs=3600
[program:laravel-worker-default]
process_name=%(program_name)s_%(process_num)02d
command=php /var/www/html/artisan queue:work redis --queue=default --sleep=3 --tries=3 --max-time=3600 --memory=512
autostart=true
autorestart=true
stopasgroup=true
killasgroup=true
user=www-data
numprocs=8
redirect_stderr=true
stdout_logfile=/var/www/html/storage/logs/worker-default.log
stopwaitsecs=3600
[program:laravel-worker-low]
process_name=%(program_name)s_%(process_num)02d
command=php /var/www/html/artisan queue:work redis --queue=low --sleep=10 --tries=2 --max-time=3600 --memory=256
autostart=true
autorestart=true
stopasgroup=true
killasgroup=true
user=www-data
numprocs=2
redirect_stderr=true
stdout_logfile=/var/www/html/storage/logs/worker-low.log
stopwaitsecs=3600
Apply supervisor configuration:
# Reload supervisor configuration
sudo supervisorctl reread
sudo supervisorctl update
# Start workers
sudo supervisorctl start laravel-worker-critical:*
sudo supervisorctl start laravel-worker-default:*
sudo supervisorctl start laravel-worker-low:*
# Check status
sudo supervisorctl status
# Expected output:
# laravel-worker-critical:laravel-worker-critical_00 RUNNING pid 12345, uptime 0:00:05
# laravel-worker-critical:laravel-worker-critical_01 RUNNING pid 12346, uptime 0:00:05
# laravel-worker-critical:laravel-worker-critical_02 RUNNING pid 12347, uptime 0:00:05
# laravel-worker-critical:laravel-worker-critical_03 RUNNING pid 12348, uptime 0:00:05
# laravel-worker-default:laravel-worker-default_00 RUNNING pid 12349, uptime 0:00:05
# ...
Stripe Webhook Security & Idempotency
The Problem: Webhook Replay Attacks and Duplicate Processing
Webhooks can arrive multiple times due to network retries, Stripe's retry logic, or malicious replay attacks. Processing payment_intent.succeeded twice charges customers double. Processing customer.subscription.deleted twice cancels services prematurely.
The solution: Signature verification, idempotency keys, and event deduplication.
Step 1: Webhook Signature Verification
Webhook controller (app/Http/Controllers/WebhookController.php):
<?php
namespace App\Http\Controllers;
use App\Services\StripeWebhookHandler;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Stripe\Exception\SignatureVerificationException;
use Stripe\Webhook;
class WebhookController extends Controller
{
/**
* Disable CSRF protection for webhook endpoints.
* Verification happens via Stripe signature instead.
*/
public function __construct()
{
// This is handled in VerifyCsrfToken middleware
}
/**
* Handle incoming Stripe webhook.
*
* Stripe signs each webhook with a secret. We verify the signature
* before processing to prevent replay attacks and ensure authenticity.
*/
public function handleStripe(Request $request, StripeWebhookHandler $handler)
{
$payload = $request->getContent();
$sigHeader = $request->header('Stripe-Signature');
$webhookSecret = config('services.stripe.webhook_secret');
try {
// Verify webhook signature
$event = Webhook::constructEvent(
$payload,
$sigHeader,
$webhookSecret
);
} catch (\UnexpectedValueException $e) {
// Invalid payload
Log::error('Invalid Stripe webhook payload', [
'error' => $e->getMessage(),
]);
return response()->json(['error' => 'Invalid payload'], 400);
} catch (SignatureVerificationException $e) {
// Invalid signature - potential attack
Log::critical('Stripe webhook signature verification failed', [
'error' => $e->getMessage(),
'ip' => $request->ip(),
'user_agent' => $request->userAgent(),
]);
return response()->json(['error' => 'Invalid signature'], 400);
}
// Check for duplicate events using idempotency
if ($this->isDuplicateEvent($event->id)) {
Log::info('Duplicate Stripe webhook event ignored', [
'event_id' => $event->id,
'type' => $event->type,
]);
return response()->json(['message' => 'Duplicate event'], 200);
}
// Store event for idempotency check and audit trail
$this->storeWebhookEvent($event);
// Handle event asynchronously
try {
$handler->handle($event);
Log::info('Stripe webhook processed', [
'event_id' => $event->id,
'type' => $event->type,
]);
return response()->json(['message' => 'Webhook processed'], 200);
} catch (\Exception $e) {
Log::error('Stripe webhook processing failed', [
'event_id' => $event->id,
'type' => $event->type,
'error' => $e->getMessage(),
]);
// Return 500 so Stripe retries
return response()->json(['error' => 'Processing failed'], 500);
}
}
/**
* Check if event was already processed.
*
* Uses event ID as idempotency key. Stripe guarantees unique IDs.
*/
private function isDuplicateEvent(string $eventId): bool
{
return \DB::table('stripe_webhook_events')
->where('event_id', $eventId)
->exists();
}
/**
* Store webhook event for audit and deduplication.
*/
private function storeWebhookEvent(\Stripe\Event $event): void
{
\DB::table('stripe_webhook_events')->insert([
'event_id' => $event->id,
'type' => $event->type,
'payload' => json_encode($event->data->object),
'processed_at' => now(),
'created_at' => now(),
]);
}
}
Migration for webhook events table:
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('stripe_webhook_events', function (Blueprint $table) {
$table->id();
$table->string('event_id')->unique();
$table->string('type');
$table->json('payload');
$table->timestamp('processed_at');
$table->timestamps();
$table->index(['type', 'created_at']);
});
}
public function down(): void
{
Schema::dropIfExists('stripe_webhook_events');
}
};
Step 2: Webhook Handler with Event-Specific Logic
Webhook handler service (app/Services/StripeWebhookHandler.php):
<?php
namespace App\Services;
use App\Models\Order;
use App\Models\User;
use App\Models\Subscription;
use App\Jobs\ProcessRefund;
use App\Jobs\SendReceiptEmail;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Stripe\Event;
class StripeWebhookHandler
{
/**
* Route webhook event to appropriate handler.
*/
public function handle(Event $event): void
{
// Use match expression for clean event routing (PHP 8.4)
match ($event->type) {
'payment_intent.succeeded' => $this->handlePaymentIntentSucceeded($event),
'payment_intent.payment_failed' => $this->handlePaymentIntentFailed($event),
'charge.refunded' => $this->handleChargeRefunded($event),
'customer.subscription.created' => $this->handleSubscriptionCreated($event),
'customer.subscription.updated' => $this->handleSubscriptionUpdated($event),
'customer.subscription.deleted' => $this->handleSubscriptionDeleted($event),
'invoice.payment_succeeded' => $this->handleInvoicePaymentSucceeded($event),
'invoice.payment_failed' => $this->handleInvoicePaymentFailed($event),
default => $this->handleUnknownEvent($event),
};
}
/**
* Handle successful payment.
*
* This confirms order payment and triggers fulfillment.
* MUST be idempotent - can be called multiple times safely.
*/
private function handlePaymentIntentSucceeded(Event $event): void
{
$paymentIntent = $event->data->object;
// Find order by payment intent ID
$order = Order::where('stripe_payment_intent_id', $paymentIntent->id)
->first();
if (!$order) {
Log::warning('Payment intent succeeded for unknown order', [
'payment_intent_id' => $paymentIntent->id,
]);
return;
}
// Idempotency: check if already processed
if ($order->status === 'paid' || $order->status === 'processing') {
Log::info('Payment intent already processed', [
'order_id' => $order->id,
'status' => $order->status,
]);
return;
}
DB::transaction(function () use ($order, $paymentIntent) {
// Update order status
$order->update([
'status' => 'paid',
'stripe_charge_id' => $paymentIntent->latest_charge,
'paid_at' => now(),
]);
// Record payment transaction
DB::table('payment_transactions')->insert([
'order_id' => $order->id,
'stripe_payment_intent_id' => $paymentIntent->id,
'stripe_charge_id' => $paymentIntent->latest_charge,
'amount' => $paymentIntent->amount,
'currency' => $paymentIntent->currency,
'status' => 'succeeded',
'metadata' => json_encode($paymentIntent->metadata),
'created_at' => now(),
]);
});
// Send receipt email asynchronously
SendReceiptEmail::dispatch($order);
Log::info('Payment intent succeeded processed', [
'order_id' => $order->id,
'payment_intent_id' => $paymentIntent->id,
'amount' => $paymentIntent->amount / 100,
]);
}
/**
* Handle failed payment.
*
* Notify customer and mark order as payment failed.
*/
private function handlePaymentIntentFailed(Event $event): void
{
$paymentIntent = $event->data->object;
$order = Order::where('stripe_payment_intent_id', $paymentIntent->id)
->first();
if (!$order) {
return;
}
// Idempotency check
if ($order->status === 'payment_failed') {
return;
}
$order->update([
'status' => 'payment_failed',
'failure_reason' => $paymentIntent->last_payment_error->message ?? 'Unknown error',
]);
// Notify customer about payment failure
// ... email notification logic
Log::warning('Payment intent failed', [
'order_id' => $order->id,
'payment_intent_id' => $paymentIntent->id,
'reason' => $paymentIntent->last_payment_error->message ?? 'Unknown',
]);
}
/**
* Handle refund.
*
* Process inventory return and customer notification.
*/
private function handleChargeRefunded(Event $event): void
{
$charge = $event->data->object;
$order = Order::where('stripe_charge_id', $charge->id)->first();
if (!$order) {
Log::warning('Refund for unknown charge', [
'charge_id' => $charge->id,
]);
return;
}
// Check if already refunded
if ($order->status === 'refunded') {
return;
}
// Process refund asynchronously (inventory return, etc.)
ProcessRefund::dispatch($order, $charge->amount_refunded);
Log::info('Charge refunded', [
'order_id' => $order->id,
'charge_id' => $charge->id,
'amount_refunded' => $charge->amount_refunded / 100,
]);
}
/**
* Handle subscription creation.
*/
private function handleSubscriptionCreated(Event $event): void
{
$subscription = $event->data->object;
// Find or create user by Stripe customer ID
$user = User::where('stripe_customer_id', $subscription->customer)
->first();
if (!$user) {
Log::error('Subscription created for unknown customer', [
'customer_id' => $subscription->customer,
'subscription_id' => $subscription->id,
]);
return;
}
// Create subscription record
Subscription::create([
'user_id' => $user->id,
'stripe_subscription_id' => $subscription->id,
'stripe_plan_id' => $subscription->items->data[0]->plan->id,
'status' => $subscription->status,
'quantity' => $subscription->quantity,
'trial_ends_at' => $subscription->trial_end
? now()->createFromTimestamp($subscription->trial_end)
: null,
'ends_at' => null,
]);
Log::info('Subscription created', [
'user_id' => $user->id,
'subscription_id' => $subscription->id,
]);
}
/**
* Handle subscription update.
*/
private function handleSubscriptionUpdated(Event $event): void
{
$subscription = $event->data->object;
$localSubscription = Subscription::where(
'stripe_subscription_id',
$subscription->id
)->first();
if (!$localSubscription) {
Log::warning('Subscription update for unknown subscription', [
'subscription_id' => $subscription->id,
]);
return;
}
$localSubscription->update([
'status' => $subscription->status,
'quantity' => $subscription->quantity,
'trial_ends_at' => $subscription->trial_end
? now()->createFromTimestamp($subscription->trial_end)
: null,
]);
Log::info('Subscription updated', [
'subscription_id' => $subscription->id,
'status' => $subscription->status,
]);
}
/**
* Handle subscription deletion/cancellation.
*/
private function handleSubscriptionDeleted(Event $event): void
{
$subscription = $event->data->object;
$localSubscription = Subscription::where(
'stripe_subscription_id',
$subscription->id
)->first();
if (!$localSubscription) {
return;
}
// Idempotency: check if already canceled
if ($localSubscription->ends_at !== null) {
return;
}
$localSubscription->update([
'status' => 'canceled',
'ends_at' => now(),
]);
// Revoke user access, send cancellation email, etc.
Log::info('Subscription deleted', [
'subscription_id' => $subscription->id,
'user_id' => $localSubscription->user_id,
]);
}
/**
* Handle invoice payment success.
*/
private function handleInvoicePaymentSucceeded(Event $event): void
{
$invoice = $event->data->object;
// Record invoice payment for accounting
DB::table('invoice_payments')->insert([
'stripe_invoice_id' => $invoice->id,
'stripe_subscription_id' => $invoice->subscription,
'amount' => $invoice->amount_paid,
'currency' => $invoice->currency,
'paid_at' => now()->createFromTimestamp($invoice->status_transitions->paid_at),
'created_at' => now(),
]);
Log::info('Invoice payment succeeded', [
'invoice_id' => $invoice->id,
'amount' => $invoice->amount_paid / 100,
]);
}
/**
* Handle invoice payment failure.
*/
private function handleInvoicePaymentFailed(Event $event): void
{
$invoice = $event->data->object;
$subscription = Subscription::where(
'stripe_subscription_id',
$invoice->subscription
)->first();
if (!$subscription) {
return;
}
// Alert customer about payment failure
// Implement dunning management (retry logic)
Log::warning('Invoice payment failed', [
'invoice_id' => $invoice->id,
'subscription_id' => $invoice->subscription,
'user_id' => $subscription->user_id,
]);
}
/**
* Log unknown event types for investigation.
*/
private function handleUnknownEvent(Event $event): void
{
Log::info('Unknown Stripe webhook event', [
'event_id' => $event->id,
'type' => $event->type,
]);
}
}
Step 3: Middleware Configuration
Disable CSRF for webhook routes (app/Http/Middleware/VerifyCsrfToken.php):
<?php
namespace App\Http\Middleware;
use Illuminate\Foundation\Http\Middleware\VerifyCsrfToken as Middleware;
class VerifyCsrfToken extends Middleware
{
/**
* URIs excluded from CSRF verification.
*
* Webhooks use signature verification instead of CSRF tokens.
*/
protected $except = [
'webhooks/stripe',
'webhooks/stripe/*',
];
}
Route configuration (routes/web.php):
Route::post('/webhooks/stripe', [WebhookController::class, 'handleStripe'])
->name('webhooks.stripe');
Step 4: Testing Webhooks Locally
Use Stripe CLI to forward webhooks:
# Install Stripe CLI
# macOS: brew install stripe/stripe-cli/stripe
# Linux: https://stripe.com/docs/stripe-cli
# Login to your Stripe account
stripe login
# Forward webhooks to local development
stripe listen --forward-to localhost:8000/webhooks/stripe
# Expected output:
# > Ready! Your webhook signing secret is whsec_xxx (^C to quit)
#
# Update your .env with this secret:
# STRIPE_WEBHOOK_SECRET=whsec_xxx
# Trigger test events
stripe trigger payment_intent.succeeded
stripe trigger payment_intent.payment_failed
stripe trigger customer.subscription.created
# Verify logs show webhook processing
tail -f storage/logs/laravel.log | grep "Stripe webhook"
Dynamic Pricing Engine
The Problem: Inflexible Pricing Logic
Hard-coded prices don't support real-world scenarios: bulk discounts ("Buy 3, get 20% off"), time-based sales ("Flash sale: 30% off until midnight"), customer-specific pricing (VIP discounts), or A/B testing different price points.
The solution: Rule-based pricing engine with stackable modifiers.
Architecture
┌──────────────────────────────────────────────────────────────┐
│ Pricing Flow │
└──────────────────────────────────────────────────────────────┘
│
▼
┌──────────────────┐
│ Base Price │
│ $100.00 │
└──────────────────┘
│
┌────────────────────┼────────────────────┐
▼ ▼ ▼
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ Quantity │ │ Time-Based │ │ Customer │
│ Discount │ │ Discount │ │ Tier │
│ -$10.00 │ │ -$15.00 │ │ -$5.00 │
└─────────────┘ └─────────────┘ └─────────────┘
│ │ │
└────────────────────┼────────────────────┘
▼
┌─────────────────┐
│ Final Price │
│ $70.00 │
└─────────────────┘
Step 1: Pricing Rules Database Schema
Migration (database/migrations/xxxx_create_pricing_rules.php):
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('pricing_rules', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->text('description')->nullable();
$table->enum('type', [
'percentage',
'fixed_amount',
'fixed_price',
'buy_x_get_y',
]);
$table->integer('priority')->default(0); // Higher = applied first
$table->json('conditions'); // Rules for when this applies
$table->json('modifiers'); // How price is modified
$table->boolean('is_stackable')->default(true);
$table->boolean('is_active')->default(true);
$table->timestamp('starts_at')->nullable();
$table->timestamp('ends_at')->nullable();
$table->timestamps();
$table->index(['is_active', 'priority']);
$table->index(['starts_at', 'ends_at']);
});
Schema::create('pricing_rule_products', function (Blueprint $table) {
$table->id();
$table->foreignId('pricing_rule_id')
->constrained()
->onDelete('cascade');
$table->foreignId('product_id')
->constrained()
->onDelete('cascade');
$table->timestamps();
$table->unique(['pricing_rule_id', 'product_id']);
});
}
public function down(): void
{
Schema::dropIfExists('pricing_rule_products');
Schema::dropIfExists('pricing_rules');
}
};
Step 2: Pricing Engine Service
Service (app/Services/PricingEngine.php):
<?php
namespace App\Services;
use App\Models\Product;
use App\Models\PricingRule;
use App\Models\User;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Log;
class PricingEngine
{
/**
* Calculate final price for product with all applicable rules.
*
* @param Product $product
* @param int $quantity
* @param User|null $user For customer-specific pricing
* @return array ['base_price', 'final_price', 'savings', 'applied_rules']
*/
public function calculate(
Product $product,
int $quantity = 1,
?User $user = null
): array {
$basePrice = $product->price;
$currentPrice = $basePrice;
$appliedRules = [];
// Get applicable pricing rules
$rules = $this->getApplicableRules($product, $quantity, $user);
// Apply rules in priority order
foreach ($rules as $rule) {
$modifier = $this->applyRule($rule, $currentPrice, $quantity, $user);
if ($modifier['discount'] > 0) {
$currentPrice = $modifier['new_price'];
$appliedRules[] = [
'rule_id' => $rule->id,
'rule_name' => $rule->name,
'discount' => $modifier['discount'],
'type' => $rule->type,
];
Log::debug('Pricing rule applied', [
'product_id' => $product->id,
'rule_id' => $rule->id,
'rule_name' => $rule->name,
'discount' => $modifier['discount'],
]);
// Stop if rule is not stackable
if (!$rule->is_stackable) {
break;
}
}
}
$finalPrice = max($currentPrice, 0); // Never go below $0
$totalSavings = ($basePrice * $quantity) - ($finalPrice * $quantity);
return [
'base_price' => $basePrice,
'final_price' => $finalPrice,
'quantity' => $quantity,
'total_base' => $basePrice * $quantity,
'total_final' => $finalPrice * $quantity,
'savings' => $totalSavings,
'savings_percentage' => $basePrice > 0
? round(($totalSavings / ($basePrice * $quantity)) * 100, 2)
: 0,
'applied_rules' => $appliedRules,
];
}
/**
* Get all applicable pricing rules for context.
*/
private function getApplicableRules(
Product $product,
int $quantity,
?User $user
): Collection {
$now = now();
return PricingRule::where('is_active', true)
->where(function ($query) use ($now) {
$query->whereNull('starts_at')
->orWhere('starts_at', '<=', $now);
})
->where(function ($query) use ($now) {
$query->whereNull('ends_at')
->orWhere('ends_at', '>=', $now);
})
->whereHas('products', function ($query) use ($product) {
$query->where('product_id', $product->id);
})
->orderBy('priority', 'desc')
->get()
->filter(function ($rule) use ($product, $quantity, $user) {
return $this->evaluateConditions(
$rule->conditions,
$product,
$quantity,
$user
);
});
}
/**
* Evaluate if rule conditions are met.
*
* Conditions format:
* [
* 'min_quantity' => 3,
* 'customer_tier' => 'vip',
* 'day_of_week' => ['monday', 'friday'],
* ]
*/
private function evaluateConditions(
array $conditions,
Product $product,
int $quantity,
?User $user
): bool {
// Check minimum quantity
if (isset($conditions['min_quantity'])
&& $quantity < $conditions['min_quantity']) {
return false;
}
// Check maximum quantity
if (isset($conditions['max_quantity'])
&& $quantity > $conditions['max_quantity']) {
return false;
}
// Check customer tier
if (isset($conditions['customer_tier']) && $user) {
if ($user->tier !== $conditions['customer_tier']) {
return false;
}
}
// Check day of week
if (isset($conditions['day_of_week'])) {
$currentDay = strtolower(now()->format('l'));
if (!in_array($currentDay, $conditions['day_of_week'])) {
return false;
}
}
// Check time range
if (isset($conditions['time_range'])) {
$currentTime = now()->format('H:i');
if ($currentTime < $conditions['time_range']['start']
|| $currentTime > $conditions['time_range']['end']) {
return false;
}
}
// Check product category
if (isset($conditions['category_id'])) {
if ($product->category_id !== $conditions['category_id']) {
return false;
}
}
return true;
}
/**
* Apply specific pricing rule.
*/
private function applyRule(
PricingRule $rule,
float $currentPrice,
int $quantity,
?User $user
): array {
$modifiers = $rule->modifiers;
$discount = 0;
$newPrice = $currentPrice;
switch ($rule->type) {
case 'percentage':
$discountPercent = $modifiers['percentage'] ?? 0;
$discount = $currentPrice * ($discountPercent / 100);
$newPrice = $currentPrice - $discount;
break;
case 'fixed_amount':
$discount = $modifiers['amount'] ?? 0;
$newPrice = max($currentPrice - $discount, 0);
break;
case 'fixed_price':
$fixedPrice = $modifiers['price'] ?? $currentPrice;
$discount = max($currentPrice - $fixedPrice, 0);
$newPrice = $fixedPrice;
break;
case 'buy_x_get_y':
// Example: Buy 2, get 1 free (effective 33% discount)
$buyQuantity = $modifiers['buy'] ?? 0;
$getQuantity = $modifiers['get'] ?? 0;
if ($quantity >= $buyQuantity) {
$freeItems = floor($quantity / $buyQuantity) * $getQuantity;
$discount = ($currentPrice * $freeItems) / $quantity;
$newPrice = $currentPrice - $discount;
}
break;
}
return [
'discount' => $discount,
'new_price' => $newPrice,
];
}
/**
* Batch calculate pricing for multiple products.
*
* Optimized to load all rules at once instead of per-product queries.
*/
public function calculateBatch(array $items, ?User $user = null): array
{
$results = [];
foreach ($items as $item) {
$results[] = $this->calculate(
product: $item['product'],
quantity: $item['quantity'],
user: $user
);
}
return $results;
}
}
Step 3: Seeder for Common Pricing Rules
Seeder (database/seeders/PricingRulesSeeder.php):
<?php
namespace Database\Seeders;
use App\Models\PricingRule;
use App\Models\Product;
use Illuminate\Database\Seeder;
class PricingRulesSeeder extends Seeder
{
public function run(): void
{
// Bulk discount: Buy 3+, get 15% off
$bulkDiscount = PricingRule::create([
'name' => 'Bulk Discount - 15% off 3+',
'description' => 'Purchase 3 or more items to receive 15% discount',
'type' => 'percentage',
'priority' => 10,
'conditions' => [
'min_quantity' => 3,
],
'modifiers' => [
'percentage' => 15,
],
'is_stackable' => false,
'is_active' => true,
]);
// Attach to all products
$bulkDiscount->products()->attach(Product::pluck('id'));
// Flash sale: 30% off on Fridays
$flashSale = PricingRule::create([
'name' => 'Flash Friday Sale',
'description' => '30% off every Friday',
'type' => 'percentage',
'priority' => 20, // Higher priority than bulk discount
'conditions' => [
'day_of_week' => ['friday'],
],
'modifiers' => [
'percentage' => 30,
],
'is_stackable' => false,
'is_active' => true,
]);
$flashSale->products()->attach(Product::pluck('id'));
// VIP customer discount: 10% off everything
$vipDiscount = PricingRule::create([
'name' => 'VIP Customer Discount',
'description' => 'VIP tier customers receive 10% off',
'type' => 'percentage',
'priority' => 5,
'conditions' => [
'customer_tier' => 'vip',
],
'modifiers' => [
'percentage' => 10,
],
'is_stackable' => true,
'is_active' => true,
]);
$vipDiscount->products()->attach(Product::pluck('id'));
// Happy hour: $5 off between 2-4 PM
$happyHour = PricingRule::create([
'name' => 'Happy Hour Special',
'description' => '$5 off between 2-4 PM',
'type' => 'fixed_amount',
'priority' => 15,
'conditions' => [
'time_range' => [
'start' => '14:00',
'end' => '16:00',
],
],
'modifiers' => [
'amount' => 5.00,
],
'is_stackable' => true,
'is_active' => true,
]);
$happyHour->products()->attach(Product::pluck('id'));
// Buy 2 get 1 free
$buy2Get1 = PricingRule::create([
'name' => 'Buy 2 Get 1 Free',
'description' => 'Purchase 2 items and get 1 free',
'type' => 'buy_x_get_y',
'priority' => 25,
'conditions' => [
'min_quantity' => 3,
],
'modifiers' => [
'buy' => 2,
'get' => 1,
],
'is_stackable' => false,
'is_active' => false, // Disabled by default
]);
$buy2Get1->products()->attach(Product::take(10)->pluck('id'));
}
}
Step 4: API Endpoint for Real-Time Price Calculation
Controller (app/Http/Controllers/Api/PricingController.php):
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\Product;
use App\Services\PricingEngine;
use Illuminate\Http\Request;
class PricingController extends Controller
{
public function __construct(
private PricingEngine $pricingEngine
) {}
/**
* Calculate price for product.
*
* GET /api/pricing/calculate?product_id=123&quantity=3
*/
public function calculate(Request $request)
{
$request->validate([
'product_id' => 'required|exists:products,id',
'quantity' => 'integer|min:1|max:1000',
]);
$product = Product::findOrFail($request->product_id);
$quantity = $request->integer('quantity', 1);
$user = $request->user();
$pricing = $this->pricingEngine->calculate($product, $quantity, $user);
return response()->json([
'product' => [
'id' => $product->id,
'name' => $product->name,
'sku' => $product->sku,
],
'pricing' => $pricing,
]);
}
/**
* Batch calculate pricing for cart.
*
* POST /api/pricing/calculate-batch
* Body: { "items": [{ "product_id": 123, "quantity": 2 }, ...] }
*/
public function calculateBatch(Request $request)
{
$request->validate([
'items' => 'required|array|min:1|max:100',
'items.*.product_id' => 'required|exists:products,id',
'items.*.quantity' => 'required|integer|min:1|max:1000',
]);
$user = $request->user();
$items = collect($request->items)->map(function ($item) {
return [
'product' => Product::find($item['product_id']),
'quantity' => $item['quantity'],
];
})->toArray();
$results = $this->pricingEngine->calculateBatch($items, $user);
return response()->json([
'items' => $results,
'totals' => [
'subtotal' => array_sum(array_column($results, 'total_base')),
'total' => array_sum(array_column($results, 'total_final')),
'savings' => array_sum(array_column($results, 'savings')),
],
]);
}
}
Advanced Search with Elasticsearch Integration
The Problem: Slow Database Full-Text Search
PostgreSQL's ILIKE queries and even full-text search struggle with:
- Typo tolerance ("iphone" vs "iphome")
- Relevance scoring across multiple fields
- Faceted search (filter by category, price range, brand simultaneously)
- Sub-100ms response times on millions of products
The solution: Elasticsearch for blazing-fast, typo-tolerant, faceted product search.
Step 1: Install and Configure Elasticsearch
Add Elasticsearch to Kubernetes (k8s/elasticsearch.yaml):
apiVersion: v1
kind: Service
metadata:
name: elasticsearch
namespace: ecommerce
spec:
type: ClusterIP
ports:
- port: 9200
targetPort: 9200
protocol: TCP
selector:
app: elasticsearch
---
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: elasticsearch
namespace: ecommerce
spec:
serviceName: elasticsearch
replicas: 3
selector:
matchLabels:
app: elasticsearch
template:
metadata:
labels:
app: elasticsearch
spec:
containers:
- name: elasticsearch
image: docker.elastic.co/elasticsearch/elasticsearch:8.11.0
ports:
- containerPort: 9200
name: http
- containerPort: 9300
name: transport
env:
- name: cluster.name
value: "ecommerce-cluster"
- name: discovery.seed_hosts
value: "elasticsearch-0.elasticsearch,elasticsearch-1.elasticsearch,elasticsearch-2.elasticsearch"
- name: cluster.initial_master_nodes
value: "elasticsearch-0,elasticsearch-1,elasticsearch-2"
- name: ES_JAVA_OPTS
value: "-Xms512m -Xmx512m"
- name: xpack.security.enabled
value: "false"
resources:
requests:
memory: "1Gi"
cpu: "500m"
limits:
memory: "2Gi"
cpu: "1000m"
volumeMounts:
- name: data
mountPath: /usr/share/elasticsearch/data
volumeClaimTemplates:
- metadata:
name: data
spec:
accessModes: ["ReadWriteOnce"]
resources:
requests:
storage: 20Gi
kubectl apply -f k8s/elasticsearch.yaml
# Wait for pods to be ready
kubectl wait --for=condition=ready pod -l app=elasticsearch -n ecommerce --timeout=300s
# Verify cluster health
kubectl exec -it elasticsearch-0 -n ecommerce -- curl -s http://localhost:9200/_cluster/health?pretty
# Expected output:
# {
# "cluster_name" : "ecommerce-cluster",
# "status" : "green",
# "number_of_nodes" : 3,
# ...
# }
Install Elasticsearch PHP client:
composer require elasticsearch/elasticsearch
Step 2: Elasticsearch Service Wrapper
Service (app/Services/ElasticsearchService.php):
<?php
namespace App/Services;
use Elastic\Elasticsearch\ClientBuilder;
use Elastic\Elasticsearch\Client;
use Illuminate\Support\Facades\Log;
class ElasticsearchService
{
private Client $client;
private string $indexName = 'products';
public function __construct()
{
$this->client = ClientBuilder::create()
->setHosts([config('services.elasticsearch.host')])
->build();
}
/**
* Create index with optimized mappings.
*
* Run once during setup or after schema changes.
*/
public function createIndex(): void
{
$params = [
'index' => $this->indexName,
'body' => [
'settings' => [
'number_of_shards' => 3,
'number_of_replicas' => 2,
'analysis' => [
'analyzer' => [
'autocomplete' => [
'tokenizer' => 'autocomplete_tokenizer',
'filter' => ['lowercase', 'asciifolding'],
],
'autocomplete_search' => [
'tokenizer' => 'lowercase',
],
],
'tokenizer' => [
'autocomplete_tokenizer' => [
'type' => 'edge_ngram',
'min_gram' => 2,
'max_gram' => 10,
'token_chars' => ['letter', 'digit'],
],
],
],
],
'mappings' => [
'properties' => [
'id' => ['type' => 'integer'],
'name' => [
'type' => 'text',
'analyzer' => 'autocomplete',
'search_analyzer' => 'autocomplete_search',
'fields' => [
'keyword' => ['type' => 'keyword'],
],
],
'description' => [
'type' => 'text',
'analyzer' => 'standard',
],
'sku' => [
'type' => 'keyword',
],
'price' => ['type' => 'float'],
'category_id' => ['type' => 'integer'],
'category_name' => [
'type' => 'keyword',
],
'brand' => [
'type' => 'keyword',
],
'stock_quantity' => ['type' => 'integer'],
'is_active' => ['type' => 'boolean'],
'created_at' => ['type' => 'date'],
'updated_at' => ['type' => 'date'],
],
],
],
];
try {
$this->client->indices()->create($params);
Log::info('Elasticsearch index created', ['index' => $this->indexName]);
} catch (\Exception $e) {
Log::error('Failed to create Elasticsearch index', [
'error' => $e->getMessage(),
]);
throw $e;
}
}
/**
* Index single product.
*/
public function indexProduct(array $product): void
{
$params = [
'index' => $this->indexName,
'id' => $product['id'],
'body' => $product,
];
try {
$this->client->index($params);
} catch (\Exception $e) {
Log::error('Failed to index product', [
'product_id' => $product['id'],
'error' => $e->getMessage(),
]);
}
}
/**
* Bulk index products (more efficient).
*/
public function bulkIndexProducts(array $products): void
{
$params = ['body' => []];
foreach ($products as $product) {
$params['body'][] = [
'index' => [
'_index' => $this->indexName,
'_id' => $product['id'],
],
];
$params['body'][] = $product;
}
try {
$response = $this->client->bulk($params);
if ($response['errors']) {
Log::warning('Bulk indexing had errors', [
'response' => $response,
]);
}
Log::info('Bulk indexed products', ['count' => count($products)]);
} catch (\Exception $e) {
Log::error('Bulk indexing failed', [
'error' => $e->getMessage(),
]);
}
}
/**
* Search products with facets and typo tolerance.
*/
public function search(
string $query,
array $filters = [],
int $page = 1,
int $perPage = 20
): array {
$from = ($page - 1) * $perPage;
$params = [
'index' => $this->indexName,
'body' => [
'from' => $from,
'size' => $perPage,
'query' => $this->buildQuery($query, $filters),
'aggs' => $this->buildAggregations(),
'sort' => [
['_score' => ['order' => 'desc']],
['created_at' => ['order' => 'desc']],
],
],
];
try {
$response = $this->client->search($params);
return $this->formatResponse($response);
} catch (\Exception $e) {
Log::error('Elasticsearch search failed', [
'query' => $query,
'error' => $e->getMessage(),
]);
return [
'hits' => [],
'total' => 0,
'facets' => [],
];
}
}
/**
* Build search query with fuzzy matching.
*/
private function buildQuery(string $query, array $filters): array
{
$must = [];
$filter = [];
// Full-text search with fuzzy matching for typos
if (!empty($query)) {
$must[] = [
'multi_match' => [
'query' => $query,
'fields' => ['name^3', 'description', 'sku^2'],
'fuzziness' => 'AUTO',
'prefix_length' => 2,
],
];
}
// Filter by category
if (isset($filters['category_id'])) {
$filter[] = ['term' => ['category_id' => $filters['category_id']]];
}
// Filter by price range
if (isset($filters['min_price']) || isset($filters['max_price'])) {
$range = [];
if (isset($filters['min_price'])) {
$range['gte'] = $filters['min_price'];
}
if (isset($filters['max_price'])) {
$range['lte'] = $filters['max_price'];
}
$filter[] = ['range' => ['price' => $range]];
}
// Filter by brand
if (isset($filters['brand'])) {
$filter[] = ['term' => ['brand' => $filters['brand']]];
}
// Only active, in-stock products
$filter[] = ['term' => ['is_active' => true]];
$filter[] = ['range' => ['stock_quantity' => ['gt' => 0]]];
return [
'bool' => [
'must' => $must,
'filter' => $filter,
],
];
}
/**
* Build aggregations for faceted search.
*/
private function buildAggregations(): array
{
return [
'categories' => [
'terms' => [
'field' => 'category_name',
'size' => 20,
],
],
'brands' => [
'terms' => [
'field' => 'brand',
'size' => 20,
],
],
'price_ranges' => [
'range' => [
'field' => 'price',
'ranges' => [
['to' => 25],
['from' => 25, 'to' => 50],
['from' => 50, 'to' => 100],
['from' => 100, 'to' => 200],
['from' => 200],
],
],
],
];
}
/**
* Format Elasticsearch response for API.
*/
private function formatResponse(array $response): array
{
$hits = array_map(function ($hit) {
return array_merge(
['id' => $hit['_id'], 'score' => $hit['_score']],
$hit['_source']
);
}, $response['hits']['hits']);
return [
'hits' => $hits,
'total' => $response['hits']['total']['value'],
'facets' => [
'categories' => $this->formatBuckets(
$response['aggregations']['categories']['buckets'] ?? []
),
'brands' => $this->formatBuckets(
$response['aggregations']['brands']['buckets'] ?? []
),
'price_ranges' => $this->formatBuckets(
$response['aggregations']['price_ranges']['buckets'] ?? []
),
],
];
}
/**
* Format aggregation buckets.
*/
private function formatBuckets(array $buckets): array
{
return array_map(function ($bucket) {
return [
'key' => $bucket['key'],
'count' => $bucket['doc_count'],
];
}, $buckets);
}
/**
* Delete product from index.
*/
public function deleteProduct(int $productId): void
{
try {
$this->client->delete([
'index' => $this->indexName,
'id' => $productId,
]);
} catch (\Exception $e) {
Log::error('Failed to delete product from Elasticsearch', [
'product_id' => $productId,
'error' => $e->getMessage(),
]);
}
}
}
Step 3: Index Products Command
Console command (app/Console/Commands/IndexProductsCommand.php):
<?php
namespace App\Console\Commands;
use App\Models\Product;
use App\Services\ElasticsearchService;
use Illuminate\Console\Command;
class IndexProductsCommand extends Command
{
protected $signature = 'elasticsearch:index-products {--fresh : Recreate index}';
protected $description = 'Index all products in Elasticsearch';
public function handle(ElasticsearchService $elasticsearch): int
{
if ($this->option('fresh')) {
$this->info('Recreating index...');
$elasticsearch->createIndex();
}
$this->info('Indexing products...');
$bar = $this->output->createProgressBar(Product::count());
Product::with('category')
->chunk(100, function ($products) use ($elasticsearch, $bar) {
$data = $products->map(function ($product) {
return [
'id' => $product->id,
'name' => $product->name,
'description' => $product->description,
'sku' => $product->sku,
'price' => $product->price,
'category_id' => $product->category_id,
'category_name' => $product->category->name ?? null,
'brand' => $product->brand,
'stock_quantity' => $product->stock_quantity,
'is_active' => $product->is_active,
'created_at' => $product->created_at->toIso8601String(),
'updated_at' => $product->updated_at->toIso8601String(),
];
})->toArray();
$elasticsearch->bulkIndexProducts($data);
$bar->advance(count($products));
});
$bar->finish();
$this->newLine();
$this->info('Products indexed successfully!');
return self::SUCCESS;
}
}
Run indexing:
php artisan elasticsearch:index-products --fresh
# Output:
# Recreating index...
# Indexing products...
# 10000/10000 [============================] 100%
# Products indexed successfully!
Common Pitfalls & Production Lessons
1. WebSocket Connection Limits
Problem: Running out of file descriptors when handling 10,000+ concurrent WebSocket connections.
Solution:
# Increase system limits
sudo vim /etc/security/limits.conf
# Add:
* soft nofile 65536
* hard nofile 65536
# Verify
ulimit -n
# Should show: 65536
2. Redis Memory Exhaustion
Problem: Redis running out of memory when caching thousands of products.
Solution: Configure eviction policy in redis.conf:
maxmemory 2gb
maxmemory-policy allkeys-lru # Evict least recently used keys
3. Queue Worker Memory Leaks
Problem: Queue workers consuming increasing memory over time, eventually crashing.
Solution: Restart workers periodically:
# In supervisor config
command=php /var/www/html/artisan queue:work --max-time=3600 --memory=512
# This restarts worker every hour or when memory exceeds 512MB
4. Elasticsearch Heap Size
Problem: Elasticsearch crashing with OutOfMemoryError under load.
Solution: Set heap to 50% of available RAM, max 31GB:
env:
- name: ES_JAVA_OPTS
value: "-Xms2g -Xmx2g" # For 4GB RAM instance
5. Stripe Webhook Timeouts
Problem: Long-running webhook handlers causing Stripe to timeout and retry.
Solution: Process webhooks asynchronously:
public function handleStripe(Request $request)
{
// Verify signature synchronously
$event = $this->verifySignature($request);
// Queue processing asynchronously
ProcessStripeWebhook::dispatch($event);
// Return 200 immediately
return response()->json(['received' => true], 200);
}
What's Next
In Part 4: Monitoring, Logging & Observability, we'll implement:
- Centralized logging with ELK Stack (Elasticsearch, Logstash, Kibana)
- Application Performance Monitoring with New Relic or DataDog
- Custom metrics and alerting with Prometheus and Grafana
- Distributed tracing for debugging microservices issues
- Error tracking with Sentry integration
- Real-time dashboards for business metrics
We'll build production-grade observability so you know exactly what's happening in your system at all times - before customers report issues.
Repository: https://github.com/iBekzod/laravel-ecommerce-platform
Questions or feedback? Open an issue on GitHub or visit https://nextgenbeing.com
Daniel Hartwell
AuthorSenior backend engineer focused on distributed systems and database performance. Previously at fintech and SaaS scale-ups. Writes about the boring-but-critical infrastructure that keeps systems running.
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 InRelated Articles
Building a Modern SaaS Application with Laravel - Part 3: Advanced Features & Configuration
Apr 25, 2026
Building a Modern SaaS Application with Laravel - Part 1: Architecture, Setup & Foundations
Apr 25, 2026
Optimizing Database Performance with Indexing and Caching: What We Learned Scaling to 100M Queries/Day
Apr 18, 2026