Building E-commerce with Laravel
E-commerce is one of the most common and rewarding types of projects a web developer can build. From handling product listings and shopping carts to processing payments and managing orders, a full e-commerce system touches nearly every aspect of backend development. Laravel is uniquely well-suited for this challenge — its expressive ORM, robust routing, built-in authentication, and rich package ecosystem mean you can build a production-quality store without reinventing the wheel.
In this guide we'll build a complete e-commerce application from the ground up: product catalog, session-based shopping cart, Stripe payment integration, order management, and a basic admin panel. Every step maps directly to a real-world requirement you'd face on the job.
A fully functional e-commerce store with product browsing, a persistent shopping cart, Stripe Checkout for payments, an order history page for customers, and an admin dashboard for managing products and orders — all built on Laravel 11.
Project Setup
Start with a fresh Laravel installation and configure the database. We'll use MySQL, though SQLite works fine for local development.
Create the Laravel project
Scaffold a new app, install dependencies, and run the dev server.
composer create-project laravel/laravel ecommerce
cd ecommerce
# Install Stripe PHP SDK
composer require stripe/stripe-php
# Install Laravel Breeze for auth scaffolding
composer require laravel/breeze --dev
php artisan breeze:install blade
npm install && npm run build
php artisan serve
Configure environment variables
Set your database credentials and Stripe API keys in .env.
DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=ecommerce
DB_USERNAME=root
DB_PASSWORD=
STRIPE_KEY=pk_test_your_publishable_key
STRIPE_SECRET=sk_test_your_secret_key
STRIPE_WEBHOOK_SECRET=whsec_your_webhook_secret
Add Stripe config
Expose Stripe keys through Laravel's config system so they can be cached.
'stripe' => [
'key' => env('STRIPE_KEY'),
'secret' => env('STRIPE_SECRET'),
'webhook_secret' => env('STRIPE_WEBHOOK_SECRET'),
],
Database Design
A well-designed schema is the backbone of any e-commerce system. We need tables for categories, products, cart items, orders, and order line items. Keeping order_items as a separate table (rather than a JSON column) means you can query and report on sales per product without parsing blobs.
E-commerce database schema — normalized for reliable reporting and clean foreign-key relationships.
Migrations
Schema::create('categories', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->string('slug')->unique();
$table->text('description')->nullable();
$table->timestamps();
});
Schema::create('products', function (Blueprint $table) {
$table->id();
$table->foreignId('category_id')->constrained()->cascadeOnDelete();
$table->string('name');
$table->string('slug')->unique();
$table->text('description')->nullable();
$table->decimal('price', 10, 2);
$table->unsignedInteger('stock')->default(0);
$table->string('image')->nullable();
$table->boolean('is_active')->default(true);
$table->timestamps();
});
Schema::create('orders', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->string('status')->default('pending'); // pending|paid|shipped|cancelled
$table->decimal('total', 10, 2);
$table->string('stripe_session_id')->nullable();
$table->json('shipping_address');
$table->timestamps();
});
Schema::create('order_items', function (Blueprint $table) {
$table->id();
$table->foreignId('order_id')->constrained()->cascadeOnDelete();
$table->foreignId('product_id')->constrained()->restrictOnDelete();
$table->unsignedInteger('quantity');
$table->decimal('price', 10, 2); // snapshot of price at purchase time
$table->timestamps();
});
Always store the price in order_items at the time of purchase — not a foreign key to the current product price. If you ever change a product's price, you must not rewrite historical order totals.
Shopping Cart Implementation
For most stores a session-based cart is the right starting point. It works without authentication, requires zero extra tables, and is trivially fast. We'll encapsulate all cart logic in a dedicated CartService class so controllers stay thin.
| Approach | Persists across devices? | Requires login? | Complexity |
|---|---|---|---|
| Session-based | No | No | Low |
| Database-backed | Yes | Yes (or guest token) | Medium |
| Cookie-based | Partially | No | Low–Medium |
| Redis-backed | Yes (TTL) | No (session key) | Medium |
<?php
namespace App\Services;
use App\Models\Product;
use Illuminate\Support\Facades\Session;
class CartService
{
private const KEY = 'cart';
public function items(): array
{
return Session::get(self::KEY, []);
}
public function add(Product $product, int $quantity = 1): void
{
$cart = $this->items();
$id = $product->id;
if (isset($cart[$id])) {
$cart[$id]['quantity'] += $quantity;
} else {
$cart[$id] = [
'id' => $id,
'name' => $product->name,
'price' => $product->price,
'image' => $product->image,
'quantity' => $quantity,
];
}
Session::put(self::KEY, $cart);
}
public function update(int $productId, int $quantity): void
{
$cart = $this->items();
if ($quantity <= 0) {
$this->remove($productId);
return;
}
$cart[$productId]['quantity'] = $quantity;
Session::put(self::KEY, $cart);
}
public function remove(int $productId): void
{
$cart = $this->items();
unset($cart[$productId]);
Session::put(self::KEY, $cart);
}
public function clear(): void
{
Session::forget(self::KEY);
}
public function total(): float
{
return array_reduce($this->items(), function (float $carry, array $item) {
return $carry + ($item['price'] * $item['quantity']);
}, 0.0);
}
public function count(): int
{
return array_sum(array_column($this->items(), 'quantity'));
}
}
Cart Controller
<?php
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 $cart) {}
public function index()
{
return view('cart.index', [
'items' => $this->cart->items(),
'total' => $this->cart->total(),
]);
}
public function add(Request $request, Product $product)
{
$request->validate(['quantity' => 'required|integer|min:1|max:99']);
$this->cart->add($product, $request->quantity);
return back()->with('success', "{$product->name} added to cart.");
}
public function update(Request $request, int $productId)
{
$request->validate(['quantity' => 'required|integer|min:0']);
$this->cart->update($productId, $request->quantity);
return back()->with('success', 'Cart updated.');
}
public function remove(int $productId)
{
$this->cart->remove($productId);
return back()->with('success', 'Item removed.');
}
}
Binding CartService in the Service Container
Register CartService as a singleton so the same instance is shared within a single request — handy when multiple controllers or view composers need the cart count.
use App\Services\CartService;
public function register(): void
{
$this->app->singleton(CartService::class);
}
Stripe Payment Integration
We'll use Stripe Checkout — Stripe's hosted payment page — rather than building a custom card form. This removes PCI-DSS scope from your server entirely, since cardholder data never touches your backend.
Stripe Checkout flow — your server only handles session creation and webhook confirmation, never raw card data.
<?php
namespace App\Http\Controllers;
use App\Models\Order;
use App\Services\CartService;
use Illuminate\Http\Request;
use Stripe\Stripe;
use Stripe\Checkout\Session as StripeSession;
class CheckoutController extends Controller
{
public function __construct(private CartService $cart) {}
public function create(Request $request)
{
$items = $this->cart->items();
if (empty($items)) {
return redirect()->route('cart.index')->with('error', 'Your cart is empty.');
}
Stripe::setApiKey(config('services.stripe.secret'));
$lineItems = array_map(fn($item) => [
'price_data' => [
'currency' => 'usd',
'unit_amount' => (int) round($item['price'] * 100), // cents
'product_data' => ['name' => $item['name']],
],
'quantity' => $item['quantity'],
], $items);
// Create a pending order before redirecting to Stripe
$order = Order::create([
'user_id' => $request->user()->id,
'status' => 'pending',
'total' => $this->cart->total(),
'shipping_address' => $request->only(['line1', 'city', 'postal_code', 'country']),
]);
foreach ($items as $item) {
$order->items()->create([
'product_id' => $item['id'],
'quantity' => $item['quantity'],
'price' => $item['price'],
]);
}
$session = StripeSession::create([
'payment_method_types' => ['card'],
'line_items' => $lineItems,
'mode' => 'payment',
'success_url' => route('checkout.success') . '?session_id={CHECKOUT_SESSION_ID}',
'cancel_url' => route('cart.index'),
'metadata' => ['order_id' => $order->id],
]);
$order->update(['stripe_session_id' => $session->id]);
return redirect($session->url, 303);
}
public function success(Request $request)
{
$this->cart->clear();
return view('checkout.success');
}
}
Stripe Webhook Handler
Relying only on the success_url redirect is fragile — users can close the browser before returning. Webhooks are the authoritative source of truth for payment confirmation.
<?php
namespace App\Http\Controllers;
use App\Models\Order;
use Illuminate\Http\Request;
use Stripe\Webhook;
use Stripe\Exception\SignatureVerificationException;
class WebhookController extends Controller
{
public function handle(Request $request)
{
$payload = $request->getContent();
$sig = $request->header('Stripe-Signature');
$secret = config('services.stripe.webhook_secret');
try {
$event = Webhook::constructEvent($payload, $sig, $secret);
} catch (SignatureVerificationException) {
return response('Invalid signature', 400);
}
if ($event->type === 'checkout.session.completed') {
$session = $event->data->object;
$orderId = $session->metadata->order_id ?? null;
if ($orderId) {
Order::where('id', $orderId)
->where('status', 'pending')
->update(['status' => 'paid']);
}
}
return response('OK', 200);
}
}
Register the webhook route outside the auth middleware group and exempt it from CSRF verification:
// Public routes
Route::get('/products', [ProductController::class, 'index'])->name('products.index');
Route::get('/products/{product:slug}', [ProductController::class, 'show'])->name('products.show');
// Cart routes
Route::prefix('cart')->name('cart.')->group(function () {
Route::get('/', [CartController::class, 'index'])->name('index');
Route::post('/add/{product}', [CartController::class, 'add'])->name('add');
Route::patch('/update/{productId}', [CartController::class, 'update'])->name('update');
Route::delete('/remove/{productId}', [CartController::class, 'remove'])->name('remove');
});
// Authenticated checkout
Route::middleware('auth')->group(function () {
Route::post('/checkout', [CheckoutController::class, 'create'])->name('checkout.create');
Route::get('/checkout/success', [CheckoutController::class, 'success'])->name('checkout.success');
Route::get('/orders', [OrderController::class, 'index'])->name('orders.index');
});
// Stripe webhook — no auth, no CSRF
Route::post('/webhook/stripe', [WebhookController::class, 'handle'])
->withoutMiddleware(['auth', \App\Http\Middleware\VerifyCsrfToken::class]);
// Admin
Route::middleware(['auth', 'admin'])->prefix('admin')->name('admin.')->group(function () {
Route::get('/dashboard', [AdminController::class, 'dashboard'])->name('dashboard');
Route::resource('products', AdminProductController::class);
Route::resource('orders', AdminOrderController::class)->only(['index', 'show', 'update']);
});
Product Catalog & Models
Eloquent models wire the database schema to your PHP code. Route model binding on the slug column produces clean, SEO-friendly URLs like /products/wireless-headphones without any controller boilerplate.
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class Product extends Model
{
protected $fillable = [
'category_id', 'name', 'slug', 'description',
'price', 'stock', 'image', 'is_active',
];
protected $casts = [
'price' => 'decimal:2',
'is_active' => 'boolean',
];
// Use slug for route model binding
public function getRouteKeyName(): string
{
return 'slug';
}
public function category(): BelongsTo
{
return $this->belongsTo(Category::class);
}
public function orderItems(): HasMany
{
return $this->hasMany(OrderItem::class);
}
public function scopeActive($query)
{
return $query->where('is_active', true)->where('stock', '>', 0);
}
}
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class Order extends Model
{
protected $fillable = [
'user_id', 'status', 'total', 'stripe_session_id', 'shipping_address',
];
protected $casts = [
'total' => 'decimal:2',
'shipping_address' => 'array',
];
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function items(): HasMany
{
return $this->hasMany(OrderItem::class);
}
public function isPaid(): bool
{
return $this->status === 'paid';
}
}
Product Controller
<?php
namespace App\Http\Controllers;
use App\Models\Category;
use App\Models\Product;
use Illuminate\Http\Request;
class ProductController extends Controller
{
public function index(Request $request)
{
$query = Product::active()->with('category');
if ($request->filled('category')) {
$query->whereHas('category', fn($q) => $q->where('slug', $request->category));
}
if ($request->filled('search')) {
$term = '%' . $request->search . '%';
$query->where(fn($q) => $q->where('name', 'like', $term)
->orWhere('description', 'like', $term));
}
$sortMap = [
'price_asc' => ['price', 'asc'],
'price_desc' => ['price', 'desc'],
'newest' => ['created_at', 'desc'],
];
[$col, $dir] = $sortMap[$request->sort] ?? ['name', 'asc'];
$products = $query->orderBy($col, $dir)->paginate(12)->withQueryString();
$categories = Category::orderBy('name')->get();
return view('products.index', compact('products', 'categories'));
}
public function show(Product $product)
{
$related = Product::active()
->where('category_id', $product->category_id)
->where('id', '!=', $product->id)
->limit(4)
->get();
return view('products.show', compact('product', 'related'));
}
}
Admin Dashboard
A lightweight admin panel gives you visibility into sales, low stock, and pending orders without reaching for a package like Nova. We'll protect it with a custom middleware that checks an is_admin flag on the user model.
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
class EnsureAdmin
{
public function handle(Request $request, Closure $next)
{
if (!$request->user()?->is_admin) {
abort(403);
}
return $next($request);
}
}
<?php
namespace App\Http\Controllers;
use App\Models\Order;
use App\Models\Product;
class AdminController extends Controller
{
public function dashboard()
{
return view('admin.dashboard', [
'totalRevenue' => Order::where('status', 'paid')->sum('total'),
'totalOrders' => Order::where('status', 'paid')->count(),
'pendingOrders' => Order::where('status', 'pending')->count(),
'lowStockCount' => Product::where('stock', '<=', 5)->where('is_active', true)->count(),
'recentOrders' => Order::with('user')->latest()->limit(10)->get(),
]);
}
}
Updating Order Status
public function update(Request $request, Order $order)
{
$request->validate([
'status' => 'required|in:pending,paid,shipped,cancelled',
]);
$order->update(['status' => $request->status]);
return back()->with('success', "Order #{$order->id} marked as {$request->status}.");
}
Security Essentials
E-commerce applications are high-value targets. Beyond Laravel's built-in CSRF protection and SQL-injection-safe query builder, there are a handful of domain-specific security measures you must implement.
- Server-side price validation: Never trust prices sent from the browser. Always look up the current product price from the database when building the Stripe line items — the code above does this correctly by reading from
$product->price, not from a form field. - Stock decrement inside a transaction: Use
DB::transaction()when creating an order and decrementing stock to prevent overselling under concurrent requests. Add aCHECK (stock >= 0)constraint at the database level as a safety net. - Verify webhook signatures: Always call
Webhook::constructEvent()with the signing secret. Without this check, anyone can POST a fakecheckout.session.completedevent and mark orders paid without paying. - Rate-limit checkout: Apply
throttle:5,1to the checkout route to prevent automated abuse. - Scope order queries to the authenticated user: In
OrderController::show(), always add->where('user_id', auth()->id())to prevent IDOR (insecure direct object reference) attacks.
use Illuminate\Support\Facades\DB;
DB::transaction(function () use ($order, $items) {
foreach ($items as $item) {
$affected = Product::where('id', $item['id'])
->where('stock', '>=', $item['quantity'])
->decrement('stock', $item['quantity']);
if ($affected === 0) {
throw new \RuntimeException("Insufficient stock for product #{$item['id']}");
}
}
$order->update(['status' => 'paid']);
});
Never commit your .env file. Use your hosting platform's secrets manager (Laravel Forge environment, AWS Parameter Store, etc.) to inject STRIPE_SECRET and STRIPE_WEBHOOK_SECRET at runtime. Rotate keys immediately if they are ever accidentally exposed.
Conclusion
You now have a production-grade e-commerce foundation built on Laravel: a normalized database schema, a clean service-layer shopping cart, Stripe Checkout for PCI-compliant payments, webhook-driven order confirmation, and a gated admin dashboard. Each layer is deliberately thin — controllers delegate to services, models stay focused on relationships and scopes, and the payment provider handles the hard parts of card processing.
From here, natural next steps include adding product image uploads via Laravel's Storage facade with an S3 driver, sending order confirmation emails with php artisan make:mail and queued jobs, or integrating Laravel Scout for full-text product search. The architecture you've built is designed to grow incrementally without requiring a rewrite.
Key Takeaways
- Encapsulate cart logic in a singleton service — keep controllers free of session manipulation.
- Use Stripe Checkout to eliminate PCI scope from your server entirely.
- Confirm payments via webhooks, not redirect callbacks — users close browsers.
- Snapshot prices in
order_itemsat purchase time to preserve accurate order history. - Decrement stock inside a database transaction with a floor check to prevent overselling.
- Always verify Stripe webhook signatures before trusting payment events.