Introduction
Building a robust e-commerce platform requires careful architecture, secure payment processing, and scalability from day one. Laravel 12 provides the perfect foundation with its elegant syntax, built-in features, and extensive ecosystem, while Stripe offers enterprise-grade payment processing with comprehensive API support.
Whether you're building a small online store or a large-scale marketplace, Laravel's modular architecture combined with Stripe's powerful payment infrastructure enables rapid development without compromising security or performance.
In this comprehensive guide, we'll build a production-ready e-commerce platform with Laravel 12 and Stripe, covering product management, shopping cart functionality, secure checkout, payment processing, order management, webhooks, and scalability considerations.
E-Commerce Architecture Overview
Core Components
- Product Catalog – Products, categories, variants, inventory
- Shopping Cart – Session/database-based cart management
- Checkout Flow – Multi-step checkout with validation
- Payment Processing – Stripe integration with 3D Secure
- Order Management – Order tracking, status updates
- Customer Area – Order history, profile management
- Admin Dashboard – Inventory, orders, analytics
Technology Stack
- Backend: Laravel 12, MySQL, Redis
- Payment: Stripe API, Stripe Webhooks
- Frontend: Livewire/Inertia.js + Vue/React
- Queue: Laravel Horizon for async processing
- Storage: S3 for product images
Database Schema Design
1. Core Tables Migration
// Products table
Schema::create('products', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->string('slug')->unique();
$table->text('description');
$table->decimal('price', 10, 2);
$table->decimal('compare_price', 10, 2)->nullable();
$table->integer('stock_quantity')->default(0);
$table->string('sku')->unique();
$table->boolean('is_active')->default(true);
$table->json('images')->nullable();
$table->json('metadata')->nullable();
$table->timestamps();
$table->softDeletes();
$table->index(['slug', 'is_active']);
});
// Categories table
Schema::create('categories', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->string('slug')->unique();
$table->text('description')->nullable();
$table->foreignId('parent_id')->nullable()->constrained('categories');
$table->integer('order')->default(0);
$table->timestamps();
});
// Product categories pivot
Schema::create('category_product', function (Blueprint $table) {
$table->foreignId('category_id')->constrained()->cascadeOnDelete();
$table->foreignId('product_id')->constrained()->cascadeOnDelete();
$table->primary(['category_id', 'product_id']);
});
// Carts table
Schema::create('carts', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->nullable()->constrained()->nullOnDelete();
$table->string('session_id')->nullable();
$table->decimal('subtotal', 10, 2)->default(0);
$table->decimal('tax', 10, 2)->default(0);
$table->decimal('total', 10, 2)->default(0);
$table->timestamps();
$table->index(['user_id', 'session_id']);
});
// Cart items table
Schema::create('cart_items', function (Blueprint $table) {
$table->id();
$table->foreignId('cart_id')->constrained()->cascadeOnDelete();
$table->foreignId('product_id')->constrained();
$table->integer('quantity')->default(1);
$table->decimal('price', 10, 2);
$table->timestamps();
});
// Orders table
Schema::create('orders', function (Blueprint $table) {
$table->id();
$table->string('order_number')->unique();
$table->foreignId('user_id')->constrained();
$table->enum('status', ['pending', 'processing', 'completed', 'cancelled', 'refunded']);
$table->decimal('subtotal', 10, 2);
$table->decimal('tax', 10, 2);
$table->decimal('shipping', 10, 2);
$table->decimal('total', 10, 2);
$table->string('stripe_payment_intent_id')->nullable();
$table->json('shipping_address');
$table->json('billing_address');
$table->text('notes')->nullable();
$table->timestamps();
$table->index(['user_id', 'status', 'order_number']);
});
// Order items table
Schema::create('order_items', function (Blueprint $table) {
$table->id();
$table->foreignId('order_id')->constrained()->cascadeOnDelete();
$table->foreignId('product_id')->constrained();
$table->string('product_name');
$table->string('sku');
$table->integer('quantity');
$table->decimal('price', 10, 2);
$table->decimal('total', 10, 2);
$table->timestamps();
});
// Payments table
Schema::create('payments', function (Blueprint $table) {
$table->id();
$table->foreignId('order_id')->constrained();
$table->string('stripe_payment_id');
$table->string('payment_method');
$table->decimal('amount', 10, 2);
$table->string('currency', 3)->default('usd');
$table->enum('status', ['pending', 'succeeded', 'failed', 'refunded']);
$table->json('metadata')->nullable();
$table->timestamps();
});
Product Management
1. Product Model
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\SoftDeletes;
class Product extends Model
{
use SoftDeletes;
protected $fillable = [
'name', 'slug', 'description', 'price', 'compare_price',
'stock_quantity', 'sku', 'is_active', 'images', 'metadata'
];
protected $casts = [
'price' => 'decimal:2',
'compare_price' => 'decimal:2',
'is_active' => 'boolean',
'images' => 'array',
'metadata' => 'array',
];
public function categories(): BelongsToMany
{
return $this->belongsToMany(Category::class);
}
public function scopeActive($query)
{
return $query->where('is_active', true)->where('stock_quantity', '>', 0);
}
public function scopeInStock($query)
{
return $query->where('stock_quantity', '>', 0);
}
public function isInStock(): bool
{
return $this->stock_quantity > 0;
}
public function decrementStock(int $quantity): void
{
$this->decrement('stock_quantity', $quantity);
}
public function getDiscountPercentAttribute(): ?float
{
if (!$this->compare_price) {
return null;
}
return round((($this->compare_price - $this->price) / $this->compare_price) * 100);
}
}
2. Product Controller
namespace App\Http\Controllers;
use App\Models\Product;
use Illuminate\Http\Request;
class ProductController extends Controller
{
public function index(Request $request)
{
$products = Product::with('categories')
->active()
->when($request->category, function ($query, $category) {
$query->whereHas('categories', function ($q) use ($category) {
$q->where('slug', $category);
});
})
->when($request->search, function ($query, $search) {
$query->where(function ($q) use ($search) {
$q->where('name', 'like', "%{$search}%")
->orWhere('description', 'like', "%{$search}%");
});
})
->orderBy($request->sort ?? 'created_at', $request->order ?? 'desc')
->paginate(24);
return view('products.index', compact('products'));
}
public function show(string $slug)
{
$product = Product::with('categories')
->where('slug', $slug)
->active()
->firstOrFail();
// Get related products
$relatedProducts = Product::active()
->whereHas('categories', function ($query) use ($product) {
$query->whereIn('categories.id', $product->categories->pluck('id'));
})
->where('id', '!=', $product->id)
->inRandomOrder()
->limit(4)
->get();
return view('products.show', compact('product', 'relatedProducts'));
}
}
Shopping Cart Implementation
1. Cart Service
namespace App\Services;
use App\Models\Cart;
use App\Models\CartItem;
use App\Models\Product;
use Illuminate\Support\Facades\Session;
class CartService
{
public function getCart()
{
if (auth()->check()) {
return Cart::with('items.product')
->firstOrCreate(['user_id' => auth()->id()]);
}
return Cart::with('items.product')
->firstOrCreate(['session_id' => Session::getId()]);
}
public function addItem(Product $product, int $quantity = 1)
{
$cart = $this->getCart();
if (!$product->isInStock()) {
throw new \Exception('Product is out of stock');
}
$cartItem = $cart->items()
->where('product_id', $product->id)
->first();
if ($cartItem) {
$newQuantity = $cartItem->quantity + $quantity;
if ($newQuantity > $product->stock_quantity) {
throw new \Exception('Not enough stock available');
}
$cartItem->update(['quantity' => $newQuantity]);
} else {
$cart->items()->create([
'product_id' => $product->id,
'quantity' => $quantity,
'price' => $product->price,
]);
}
$this->recalculateCart($cart);
return $cart;
}
public function updateQuantity(CartItem $item, int $quantity)
{
if ($quantity <= 0) {
return $this->removeItem($item);
}
if ($quantity > $item->product->stock_quantity) {
throw new \Exception('Not enough stock available');
}
$item->update(['quantity' => $quantity]);
$this->recalculateCart($item->cart);
return $item->cart;
}
public function removeItem(CartItem $item)
{
$cart = $item->cart;
$item->delete();
$this->recalculateCart($cart);
return $cart;
}
public function clearCart(Cart $cart)
{
$cart->items()->delete();
$this->recalculateCart($cart);
}
protected function recalculateCart(Cart $cart)
{
$cart->load('items.product');
$subtotal = $cart->items->sum(function ($item) {
return $item->price * $item->quantity;
});
$tax = $subtotal * 0.1; // 10% tax
$total = $subtotal + $tax;
$cart->update([
'subtotal' => $subtotal,
'tax' => $tax,
'total' => $total,
]);
}
}
2. Cart Controller
namespace App\Http\Controllers;
use App\Models\Product;
use App\Services\CartService;
use Illuminate\Http\Request;
class CartController extends Controller
{
public function __construct(
private CartService $cartService
) {}
public function index()
{
$cart = $this->cartService->getCart();
return view('cart.index', compact('cart'));
}
public function add(Request $request, Product $product)
{
$request->validate([
'quantity' => 'required|integer|min:1',
]);
try {
$this->cartService->addItem($product, $request->quantity);
return back()->with('success', 'Product added to cart');
} catch (\Exception $e) {
return back()->withErrors(['error' => $e->getMessage()]);
}
}
public function update(Request $request, CartItem $item)
{
try {
$this->cartService->updateQuantity($item, $request->quantity);
return back()->with('success', 'Cart updated');
} catch (\Exception $e) {
return back()->withErrors(['error' => $e->getMessage()]);
}
}
public function remove(CartItem $item)
{
$this->cartService->removeItem($item);
return back()->with('success', 'Item removed from cart');
}
}
Stripe Integration
1. Install Stripe SDK
composer require stripe/stripe-php
2. Stripe Configuration
Add to .env:
STRIPE_KEY=pk_test_your_publishable_key
STRIPE_SECRET=sk_test_your_secret_key
STRIPE_WEBHOOK_SECRET=whsec_your_webhook_secret
Configure in config/services.php:
'stripe' => [
'key' => env('STRIPE_KEY'),
'secret' => env('STRIPE_SECRET'),
'webhook_secret' => env('STRIPE_WEBHOOK_SECRET'),
],
3. Payment Service
namespace App\Services;
use App\Models\Order;
use Stripe\StripeClient;
use Stripe\Exception\ApiErrorException;
class PaymentService
{
private StripeClient $stripe;
public function __construct()
{
$this->stripe = new StripeClient(config('services.stripe.secret'));
}
public function createPaymentIntent(Order $order): array
{
try {
$paymentIntent = $this->stripe->paymentIntents->create([
'amount' => $order->total * 100, // Convert to cents
'currency' => 'usd',
'metadata' => [
'order_id' => $order->id,
'order_number' => $order->order_number,
],
'automatic_payment_methods' => [
'enabled' => true,
],
]);
$order->update([
'stripe_payment_intent_id' => $paymentIntent->id,
]);
return [
'clientSecret' => $paymentIntent->client_secret,
'paymentIntentId' => $paymentIntent->id,
];
} catch (ApiErrorException $e) {
throw new \Exception('Payment initialization failed: ' . $e->getMessage());
}
}
public function confirmPayment(string $paymentIntentId): bool
{
try {
$paymentIntent = $this->stripe->paymentIntents->retrieve($paymentIntentId);
return $paymentIntent->status === 'succeeded';
} catch (ApiErrorException $e) {
return false;
}
}
public function refundPayment(Order $order, float $amount = null): bool
{
try {
$refund = $this->stripe->refunds->create([
'payment_intent' => $order->stripe_payment_intent_id,
'amount' => $amount ? $amount * 100 : null,
]);
return $refund->status === 'succeeded';
} catch (ApiErrorException $e) {
throw new \Exception('Refund failed: ' . $e->getMessage());
}
}
}
Checkout Process
1. Order Service
namespace App\Services;
use App\Models\Cart;
use App\Models\Order;
use Illuminate\Support\Str;
class OrderService
{
public function createFromCart(Cart $cart, array $shippingAddress, array $billingAddress): Order
{
$order = Order::create([
'order_number' => $this->generateOrderNumber(),
'user_id' => auth()->id(),
'status' => 'pending',
'subtotal' => $cart->subtotal,
'tax' => $cart->tax,
'shipping' => 0, // Calculate shipping
'total' => $cart->total,
'shipping_address' => $shippingAddress,
'billing_address' => $billingAddress,
]);
foreach ($cart->items as $item) {
$order->items()->create([
'product_id' => $item->product_id,
'product_name' => $item->product->name,
'sku' => $item->product->sku,
'quantity' => $item->quantity,
'price' => $item->price,
'total' => $item->price * $item->quantity,
]);
// Decrement stock
$item->product->decrementStock($item->quantity);
}
return $order;
}
protected function generateOrderNumber(): string
{
return 'ORD-' . strtoupper(Str::random(10));
}
public function updateStatus(Order $order, string $status): void
{
$order->update(['status' => $status]);
// Send notification
$order->user->notify(new OrderStatusUpdated($order));
}
}
2. Checkout Controller
namespace App\Http\Controllers;
use App\Services\{CartService, OrderService, PaymentService};
use Illuminate\Http\Request;
class CheckoutController extends Controller
{
public function __construct(
private CartService $cartService,
private OrderService $orderService,
private PaymentService $paymentService
) {}
public function index()
{
$cart = $this->cartService->getCart();
if ($cart->items->isEmpty()) {
return redirect()->route('cart.index')
->with('error', 'Your cart is empty');
}
return view('checkout.index', compact('cart'));
}
public function process(Request $request)
{
$validated = $request->validate([
'shipping_address' => 'required|array',
'billing_address' => 'required|array',
]);
$cart = $this->cartService->getCart();
// Create order
$order = $this->orderService->createFromCart(
$cart,
$validated['shipping_address'],
$validated['billing_address']
);
// Create payment intent
$paymentIntent = $this->paymentService->createPaymentIntent($order);
// Clear cart
$this->cartService->clearCart($cart);
return view('checkout.payment', [
'order' => $order,
'clientSecret' => $paymentIntent['clientSecret'],
]);
}
public function success(Order $order)
{
// Verify payment
if ($this->paymentService->confirmPayment($order->stripe_payment_intent_id)) {
$this->orderService->updateStatus($order, 'processing');
return view('checkout.success', compact('order'));
}
return redirect()->route('checkout.index')
->with('error', 'Payment verification failed');
}
}
Stripe Webhooks
1. Webhook Controller
namespace App\Http\Controllers;
use App\Models\Order;
use App\Services\OrderService;
use Illuminate\Http\Request;
use Stripe\Webhook;
use Stripe\Exception\SignatureVerificationException;
class StripeWebhookController extends Controller
{
public function __construct(
private OrderService $orderService
) {}
public function handle(Request $request)
{
$payload = $request->getContent();
$signature = $request->header('Stripe-Signature');
try {
$event = Webhook::constructEvent(
$payload,
$signature,
config('services.stripe.webhook_secret')
);
} catch (SignatureVerificationException $e) {
return response()->json(['error' => 'Invalid signature'], 400);
}
switch ($event->type) {
case 'payment_intent.succeeded':
$this->handlePaymentSucceeded($event->data->object);
break;
case 'payment_intent.payment_failed':
$this->handlePaymentFailed($event->data->object);
break;
case 'charge.refunded':
$this->handleRefund($event->data->object);
break;
}
return response()->json(['status' => 'success']);
}
protected function handlePaymentSucceeded($paymentIntent)
{
$order = Order::where('stripe_payment_intent_id', $paymentIntent->id)->first();
if ($order) {
$order->payments()->create([
'stripe_payment_id' => $paymentIntent->id,
'payment_method' => $paymentIntent->payment_method,
'amount' => $paymentIntent->amount / 100,
'currency' => $paymentIntent->currency,
'status' => 'succeeded',
]);
$this->orderService->updateStatus($order, 'processing');
}
}
protected function handlePaymentFailed($paymentIntent)
{
$order = Order::where('stripe_payment_intent_id', $paymentIntent->id)->first();
if ($order) {
$this->orderService->updateStatus($order, 'cancelled');
}
}
protected function handleRefund($charge)
{
$order = Order::where('stripe_payment_intent_id', $charge->payment_intent)->first();
if ($order) {
$this->orderService->updateStatus($order, 'refunded');
}
}
}
2. Register Webhook Route
// routes/web.php
Route::post('/stripe/webhook', [StripeWebhookController::class, 'handle'])
->withoutMiddleware([\App\Http\Middleware\VerifyCsrfToken::class]);
Scalability Considerations
1. Queue Jobs for Processing
// Process order asynchronously
dispatch(new ProcessOrder($order));
// Send order confirmation email
dispatch(new SendOrderConfirmation($order));
// Update inventory
dispatch(new UpdateInventory($orderItems));
2. Database Optimization
// Add indexes for frequently queried columns
Schema::table('products', function (Blueprint $table) {
$table->index(['slug', 'is_active']);
$table->index('stock_quantity');
});
Schema::table('orders', function (Blueprint $table) {
$table->index(['user_id', 'status']);
$table->index('order_number');
});
3. Cache Product Catalog
$products = Cache::remember('products:featured', 3600, function () {
return Product::active()
->with('categories')
->limit(12)
->get();
});
4. Load Balancing
upstream laravel_backend {
least_conn;
server app1.example.com:9000;
server app2.example.com:9000;
server app3.example.com:9000;
}
Conclusion
Building a scalable e-commerce platform with Laravel 12 and Stripe provides enterprise-grade payment processing, robust order management, and the flexibility to scale as your business grows. By implementing proper database design, service-oriented architecture, asynchronous processing, and following Laravel best practices, you create a foundation for sustainable e-commerce growth.
Key takeaways:
- Design normalized database schema for products and orders
- Implement service layer for business logic separation
- Integrate Stripe for secure payment processing
- Handle webhooks for payment status updates
- Use queues for asynchronous order processing
- Implement caching for improved performance
- Plan for horizontal scaling from the start
Need help building your e-commerce platform? NeedLaravelSite specializes in custom e-commerce development with Laravel. Contact us for expert Laravel development services.
Related Resources: