Backend

Building E-commerce with Laravel

Mayur Dabhi
Mayur Dabhi
May 24, 2026
16 min read

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.

What You'll Build

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.

1

Create the Laravel project

Scaffold a new app, install dependencies, and run the dev server.

Terminal
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
2

Configure environment variables

Set your database credentials and Stripe API keys in .env.

.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
3

Add Stripe config

Expose Stripe keys through Laravel's config system so they can be cached.

config/services.php — add inside the array
'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.

categories id (PK) name slug description products id (PK) category_id (FK) name slug price stock image / description orders id (PK) user_id (FK) status total stripe_session_id shipping_address order_items id (PK) order_id (FK) product_id (FK) quantity / price

E-commerce database schema — normalized for reliable reporting and clean foreign-key relationships.

Migrations

database/migrations/create_categories_table.php
Schema::create('categories', function (Blueprint $table) {
    $table->id();
    $table->string('name');
    $table->string('slug')->unique();
    $table->text('description')->nullable();
    $table->timestamps();
});
database/migrations/create_products_table.php
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();
});
database/migrations/create_orders_table.php
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();
});
Snapshot Prices

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-basedNoNoLow
Database-backedYesYes (or guest token)Medium
Cookie-basedPartiallyNoLow–Medium
Redis-backedYes (TTL)No (session key)Medium
app/Services/CartService.php
<?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

app/Http/Controllers/CartController.php
<?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.

app/Providers/AppServiceProvider.php
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.

Customer Laravel creates session Stripe Checkout page Webhook confirms payment

Stripe Checkout flow — your server only handles session creation and webhook confirmation, never raw card data.

app/Http/Controllers/CheckoutController.php
<?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.

app/Http/Controllers/WebhookController.php
<?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:

routes/web.php
// 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.

app/Models/Product.php
<?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);
    }
}
app/Models/Order.php
<?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

app/Http/Controllers/ProductController.php
<?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.

app/Http/Middleware/EnsureAdmin.php
<?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);
    }
}
app/Http/Controllers/AdminController.php
<?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

app/Http/Controllers/AdminOrderController.php — update method
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.

Safe stock decrement inside a transaction
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']);
});
Environment Variables in Production

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_items at 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.
Laravel E-commerce PHP Stripe Shopping Cart Project
Mayur Dabhi

Mayur Dabhi

Full Stack Developer with 5+ years of experience building scalable web applications with Laravel, React, and Node.js.