Building Production E-commerce with Laravel: Real Implementation Guide - NextGenBeing Building Production E-commerce with Laravel: Real Implementation Guide - NextGenBeing
Back to discoveries

Building a Complete E-commerce Website with Laravel: A Production-Grade Implementation

Learn how we built a high-traffic e-commerce platform handling 50k+ daily orders using Laravel. Real architecture decisions, performance optimizations, and lessons learned from production.

Growth & Distribution Premium Content 44 min read
Daniel Hartwell

Daniel Hartwell

May 19, 2026 1 views
Building a Complete E-commerce Website with Laravel: A Production-Grade Implementation
Photo by Markus Spiske on Unsplash
Size:
Height:
📖 44 min read 📝 15,930 words 👁 Focus mode: ✨ Eye care:

Listen to Article

Loading...
0:00 / 0:00
0:00 0:00
Low High
0% 100%
⏸ Paused ▶️ Now playing... Ready to play ✓ Finished

Last year, my team was tasked with rebuilding our company's aging e-commerce platform that was buckling under 50,000 daily orders. The old system—a frankensteined WordPress/WooCommerce setup—was showing cracks everywhere: checkout timeouts, inventory sync issues, and a payment gateway that failed 3% of the time during peak hours. We chose Laravel 11.x for the rebuild, and I'm going to walk you through exactly how we architected it, what failed spectacularly, and what we'd do differently.

This isn't a "hello world" shopping cart tutorial. I'm going to show you the production-grade architecture we used, complete with database schemas, caching strategies, queue configurations, payment integrations, and the performance optimizations that took us from 800ms average response times to under 120ms. I'll share the actual error messages we hit, the architectural decisions we debated at 2am during outages, and the hard-won lessons from scaling an e-commerce platform.

The Architecture We Landed On (After Two Failed Attempts)

Our first attempt used a monolithic approach with everything in controllers. Bad idea. When Black Friday hit, our server melted down because product page requests were blocking checkout operations. We couldn't scale horizontally because sessions were stored in files, and our product search was hitting the database directly for every query.

Our second attempt went too far in the other direction—we tried microservices with separate services for products, orders, payments, and inventory. The distributed transaction nightmare that followed taught me why you shouldn't start with microservices. The coordination overhead killed us, and debugging issues across service boundaries was a nightmare.

What finally worked was a modular monolith with clear domain boundaries, aggressive caching, and asynchronous processing for everything that didn't need to be synchronous. Here's the high-level architecture:

Core Modules:

  • Product Catalog: Products, categories, attributes, inventory tracking
  • Shopping Cart: Session-based carts with Redis persistence
  • Checkout: Order processing, payment integration, order fulfillment
  • Customer Management: Authentication, profiles, addresses, order history
  • Admin Panel: Product management, order processing, analytics

Infrastructure:

  • Database: PostgreSQL 15 for transactional data
  • Cache: Redis 7.x for sessions, cart data, and page caching
  • Queue: Redis-backed queues with Laravel Horizon for job monitoring
  • Search: Meilisearch for product search (we tried Elasticsearch first—overkill and expensive)
  • Storage: S3-compatible object storage for product images
  • CDN: CloudFlare for static assets and image optimization

Let me walk you through each component with the actual code and configurations we used.

Database Schema: Getting the Foundation Right

I've seen too many e-commerce projects fail because they tried to be clever with their database design. Our schema is intentionally straightforward—normalized where it matters, denormalized where performance demanded it.

Here's our core products table migration:

Schema::create('products', function (Blueprint $table) {
    $table->id();
    $table->string('sku')->unique();
    $table->string('slug')->unique();
    $table->string('name');
    $table->text('description');
    $table->text('short_description')->nullable();
    $table->decimal('price', 10, 2);
    $table->decimal('compare_at_price', 10, 2)->nullable();
    $table->decimal('cost', 10, 2)->nullable(); // For margin calculations
    $table->integer('stock_quantity')->default(0);
    $table->boolean('track_inventory')->default(true);
    $table->enum('stock_status', ['in_stock', 'out_of_stock', 'backorder'])->default('in_stock');
    $table->boolean('is_active')->default(true);
    $table->boolean('is_featured')->default(false);
    $table->integer('sort_order')->default(0);
    $table->json('metadata')->nullable(); // For flexible attributes
    $table->timestamp('published_at')->nullable();
    $table->timestamps();
    $table->softDeletes();
    
    // Critical indexes
    $table->index(['is_active', 'published_at']);
    $table->index('is_featured');
    $table->index('sort_order');
});

Notice the metadata JSON column? That was a late addition after we realized we couldn't predict all the product attributes clients would need. Some products need dimensions, others need color variants, some need technical specifications. Rather than creating 50 different columns or a complex EAV pattern, we use JSON for flexible attributes and index specific fields when needed:

// Create a generated column for searchable metadata
DB::statement("
    ALTER TABLE products 
    ADD COLUMN metadata_search TEXT GENERATED ALWAYS AS (metadata::text) STORED
");
DB::statement("CREATE INDEX idx_products_metadata_search ON products USING gin(to_tsvector('english', metadata_search))");

This lets us do full-text search on metadata without the overhead of constantly converting JSON to text.

Our categories use nested sets (via Laravel's kalnoy/nestedset package) instead of adjacency lists. I know nested sets get criticized, but for read-heavy workloads like product catalogs, they're perfect. We can fetch entire category trees with a single query:

Schema::create('categories', function (Blueprint $table) {
    $table->id();
    $table->string('name');
    $table->string('slug')->unique();
    $table->text('description')->nullable();
    $table->string('image')->nullable();
    $table->boolean('is_active')->default(true);
    $table->integer('sort_order')->default(0);
    $table->nestedSet(); // Adds _lft, _rgt, parent_id
    $table->timestamps();
});

The product-category relationship is many-to-many because products can belong to multiple categories:

Schema::create('category_product', function (Blueprint $table) {
    $table->foreignId('category_id')->constrained()->cascadeOnDelete();
    $table->foreignId('product_id')->constrained()->cascadeOnDelete();
    $table->integer('sort_order')->default(0);
    $table->timestamps();
    
    $table->primary(['category_id', 'product_id']);
    $table->index('sort_order');
});

Now here's where it gets interesting—our orders table. The mistake I see constantly is trying to normalize everything. We denormalize customer and product data into the order at creation time because we need a historical snapshot:

Schema::create('orders', function (Blueprint $table) {
    $table->id();
    $table->string('order_number')->unique();
    $table->foreignId('user_id')->nullable()->constrained()->nullOnDelete();
    
    // Denormalized customer data (snapshot at order time)
    $table->string('customer_email');
    $table->string('customer_first_name');
    $table->string('customer_last_name');
    $table->string('customer_phone')->nullable();
    
    // Shipping address (denormalized)
    $table->json('shipping_address');
    $table->json('billing_address');
    
    // Financial data
    $table->decimal('subtotal', 10, 2);
    $table->decimal('tax_amount', 10, 2)->default(0);
    $table->decimal('shipping_amount', 10, 2)->default(0);
    $table->decimal('discount_amount', 10, 2)->default(0);
    $table->decimal('total', 10, 2);
    $table->string('currency', 3)->default('USD');
    
    // Order status
    $table->enum('status', [
        'pending',
        'processing',
        'completed',
        'cancelled',
        'refunded',
        'failed'
    ])->default('pending');
    
    $table->enum('payment_status', [
        'pending',
        'authorized',
        'paid',
        'failed',
        'refunded',
        'partially_refunded'
    ])->default('pending');
    
    $table->enum('fulfillment_status', [
        'unfulfilled',
        'partially_fulfilled',
        'fulfilled',
        'returned'
    ])->default('unfulfilled');
    
    // Payment data
    $table->string('payment_method')->nullable();
    $table->string('payment_gateway')->nullable();
    $table->string('transaction_id')->nullable();
    $table->json('payment_metadata')->nullable();
    
    // Tracking
    $table->string('shipping_carrier')->nullable();
    $table->string('tracking_number')->nullable();
    $table->timestamp('shipped_at')->nullable();
    $table->timestamp('delivered_at')->nullable();
    
    // Admin notes
    $table->text('customer_notes')->nullable();
    $table->text('admin_notes')->nullable();
    
    $table->timestamps();
    $table->softDeletes();
    
    // Indexes for common queries
    $table->index('order_number');
    $table->index(['user_id', 'created_at']);
    $table->index(['status', 'created_at']);
    $table->index('payment_status');
    $table->index('fulfillment_status');
    $table->index('transaction_id');
});

Order items also denormalize product data:

Schema::create('order_items', function (Blueprint $table) {
    $table->id();
    $table->foreignId('order_id')->constrained()->cascadeOnDelete();
    $table->foreignId('product_id')->nullable()->constrained()->nullOnDelete();
    
    // Denormalized product data (snapshot at order time)
    $table->string('product_sku');
    $table->string('product_name');
    $table->json('product_options')->nullable(); // Size, color, etc.
    
    $table->integer('quantity');
    $table->decimal('unit_price', 10, 2);
    $table->decimal('subtotal', 10, 2);
    $table->decimal('tax_amount', 10, 2)->default(0);
    $table->decimal('discount_amount', 10, 2)->default(0);
    $table->decimal('total', 10, 2);
    
    $table->timestamps();
    
    $table->index('order_id');
    $table->index('product_id');
});

Why denormalize? Because when a customer views an order from 2 years ago, we need to show exactly what they ordered—even if that product has since been deleted, renamed, or repriced. Trust me, you don't want to deal with customers complaining that their order history shows wrong prices because you're pulling current product data.

The Product Catalog: Repository Pattern Done Right

I've worked on codebases where "repository pattern" meant dumping everything into a repository class until it became a 3000-line god object. Here's how we actually structure it:

namespace App\Repositories;

use App\Models\Product;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;

class ProductRepository
{
    public function find(int $id): ?Product
    {
        return Cache::tags(['products'])->remember(
            "product.{$id}",
            now()->addHour(),
            fn() => Product::with(['categories', 'images'])->find($id)
        );
    }
    
    public function findBySlug(string $slug): ?Product
    {
        return Cache::tags(['products'])->remember(
            "product.slug.{$slug}",
            now()->addHour(),
            fn() => Product::with(['categories', 'images'])
                ->where('slug', $slug)
                ->where('is_active', true)
                ->first()
        );
    }
    
    public function getFeatured(int $limit = 10): Collection
    {
        return Cache::tags(['products', 'featured'])->remember(
            "products.featured.{$limit}",
            now()->addHours(6),
            fn() => Product::with(['images'])
                ->where('is_active', true)
                ->where('is_featured', true)
                ->orderBy('sort_order')
                ->limit($limit)
                ->get()
        );
    }
    
    public function getByCategory(
        int $categoryId,
        array $filters = [],
        int $perPage = 24
    ): LengthAwarePaginator {
        $query = Product::query()
            ->select('products.*')
            ->join('category_product', 'products.id', '=', 'category_product.product_id')
            ->where('category_product.category_id', $categoryId)
            ->where('products.is_active', true);
        
        // Apply filters
        if (!empty($filters['min_price'])) {
            $query->where('products.price', '>=', $filters['min_price']);
        }
        
        if (!empty($filters['max_price'])) {
            $query->where('products.price', 'where('is_active', true)
            ->where(function($query) use ($term) {
                $query->where('name', 'ILIKE', "%{$term}%")
                    ->orWhere('description', 'ILIKE', "%{$term}%")
                    ->orWhere('sku', 'ILIKE', "%{$term}%");
            })
            ->with(['images'])
            ->paginate($perPage);
    }
    
    public function updateStock(int $productId, int $quantity): bool
    {
        $updated = DB::table('products')
            ->where('id', $productId)
            ->where('track_inventory', true)
            ->update([
                'stock_quantity' => DB::raw("stock_quantity + {$quantity}"),
                'stock_status' => DB::raw("
                    CASE 
                        WHEN stock_quantity + {$quantity} > 0 THEN 'in_stock'
                        ELSE 'out_of_stock'
                    END
                "),
                'updated_at' => now()
            ]);
        
        if ($updated) {
            Cache::tags(['products'])->forget("product.{$productId}");
        }
        
        return (bool) $updated;
    }
    
    public function decrementStock(int $productId, int $quantity): bool
    {
        return $this->updateStock($productId, -$quantity);
    }
}

Notice how we're using cache tags? That's crucial. When a product updates, we can invalidate all related caches:

// In ProductObserver
public function updated(Product $product): void
{
    Cache::tags(['products'])->forget("product.{$product->id}");
    Cache::tags(['products'])->forget("product.slug.{$product->slug}");
    
    if ($product->is_featured) {
        Cache::tags(['featured'])->flush();
    }
}

The updateStock method uses a raw SQL increment to avoid race conditions. We learned this the hard way during a flash sale when concurrent requests were causing stock to go negative. The database-level increment is atomic—no more overselling.

Shopping Cart: Session-Based with Redis Persistence

Here's a controversial opinion: for most e-commerce sites, you don't need a database-backed cart. Session-based carts with Redis are faster, simpler, and scale better. We only persist carts to the database when a user logs in or abandons the cart (for recovery emails).

Our cart service:

namespace App\Services;

use App\Models\Product;
use Illuminate\Support\Facades\Session;
use Illuminate\Support\Collection;

class CartService
{
    private const SESSION_KEY = 'cart';
    
    public function add(int $productId, int $quantity = 1, array $options = []): void
    {
        $product = Product::findOrFail($productId);
        
        // Check stock
        if ($product->track_inventory && $product->stock_quantity < $quantity) {
            throw new \Exception("Insufficient stock for {$product->name}");
        }
        
        $cart = $this->get();
        $itemKey = $this->generateItemKey($productId, $options);
        
        if (isset($cart[$itemKey])) {
            $cart[$itemKey]['quantity'] += $quantity;
        } else {
            $cart[$itemKey] = [
                'product_id' => $productId,
                'sku' => $product->sku,
                'name' => $product->name,
                'price' => $product->price,
                'quantity' => $quantity,
                'options' => $options,
                'image' => $product->images->first()?->url,
            ];
        }
        
        Session::put(self::SESSION_KEY, $cart);
    }
    
    public function update(string $itemKey, int $quantity): void
    {
        $cart = $this->get();
        
        if (!isset($cart[$itemKey])) {
            throw new \Exception('Item not found in cart');
        }
        
        if ($quantity track_inventory && $product->stock_quantity < $quantity) {
                throw new \Exception("Insufficient stock");
            }
            
            $cart[$itemKey]['quantity'] = $quantity;
        }
        
        Session::put(self::SESSION_KEY, $cart);
    }
    
    public function remove(string $itemKey): void
    {
        $cart = $this->get();
        unset($cart[$itemKey]);
        Session::put(self::SESSION_KEY, $cart);
    }
    
    public function get(): array
    {
        return Session::get(self::SESSION_KEY, []);
    }
    
    public function items(): Collection
    {
        return collect($this->get());
    }
    
    public function count(): int
    {
        return $this->items()->sum('quantity');
    }
    
    public function subtotal(): float
    {
        return $this->items()->sum(fn($item) => $item['price'] * $item['quantity']);
    }
    
    public function clear(): void
    {
        Session::forget(self::SESSION_KEY);
    }
    
    private function generateItemKey(int $productId, array $options): string
    {
        ksort($options);
        return md5($productId . json_encode($options));
    }
    
    public function validateStock(): array
    {
        $errors = [];
        $cart = $this->get();
        
        foreach ($cart as $key => $item) {
            $product = Product::find($item['product_id']);
            
            if (!$product || !$product->is_active) {
                $errors[] = "{$item['name']} is no longer available";
                unset($cart[$key]);
                continue;
            }
            
            if ($product->track_inventory && $product->stock_quantity < $item['quantity']) {
                $errors[] = "Only {$product->stock_quantity} units of {$item['name']} available";
                $cart[$key]['quantity'] = $product->stock_quantity;
            }
            
            // Update price if changed
            if ($product->price != $item['price']) {
                $cart[$key]['price'] = $product->price;
            }
        }
        
        Session::put(self::SESSION_KEY, $cart);
        
        return $errors;
    }
}

The validateStock() method is critical—we call it before checkout to ensure products are still available and prices haven't changed. We learned this lesson when a pricing update went live mid-checkout and customers were charged old prices.

Our Redis session configuration in config/session.php:

'driver' => env('SESSION_DRIVER', 'redis'),
'connection' => 'session',
'lifetime' => 120, // 2 hours
'expire_on_close' => false,

And in config/database.php:

'redis' => [
    'session' => [
        'url' => env('REDIS_URL'),
        'host' => env('REDIS_HOST', '127.0.0.1'),
        'password' => env('REDIS_PASSWORD'),
        'port' => env('REDIS_PORT', 6379),
        'database' => 1, // Separate database for sessions
        'read_timeout' => 60,
        'context' => [
            'stream' => [
                'verify_peer' => false,
                'verify_peer_name' => false,
            ],
        ],
    ],
],

Checkout Flow: Transactions, Locks, and Payment Integration

The checkout process is where most e-commerce platforms fall apart under load. Here's our battle-tested checkout service that handles race conditions, payment failures, and inventory management:

namespace App\Services;

use App\Models\Order;
use App\Models\OrderItem;
use App\Jobs\SendOrderConfirmationEmail;
use App\Jobs\UpdateInventory;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Stripe\PaymentIntent;
use Stripe\Stripe;

class CheckoutService
{
    public function __construct(
        private CartService $cartService,
        private ProductRepository $productRepository
    ) {
        Stripe::setApiKey(config('services.stripe.secret'));
    }
    
    public function createOrder(array $data): Order
    {
        // Validate cart has items
        $cartItems = $this->cartService->items();
        if ($cartItems->isEmpty()) {
            throw new \Exception('Cart is empty');
        }
        
        // Validate stock before processing
        $stockErrors = $this->cartService->validateStock();
        if (!

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

Join 10,000+ developers who love our premium content

Daniel Hartwell

Daniel Hartwell

Author

Covers backend systems, distributed architecture, and database performance. Contributing author at NextGenBeing.

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

Related Articles

Don't miss the next deep dive

Get one well-researched tutorial in your inbox each week. No spam, unsubscribe anytime.