Daniel Hartwell
Listen to Article
Loading...Building a Production-Grade E-Commerce Platform with Laravel 12, Stripe, and Kubernetes - Part 1: Architecture, Setup & Foundations
Estimated Reading Time: 22 minutes | Difficulty: Intermediate to Advanced
Table of Contents
- Why Build E-Commerce on Kubernetes?
- Architecture Overview
- Technology Stack Decisions
- Local Development Environment Setup
- Project Scaffolding and Structure
- Database Architecture and Migrations
- Docker Configuration for Local Development
- First Working API Endpoint
- Common Setup Mistakes
- What's Next
Why Build E-Commerce on Kubernetes?
After deploying e-commerce platforms that handle hundreds of thousands of transactions monthly, I've learned that the technology choices you make on day one determine your operational overhead for years. Let's be clear: Kubernetes is not for every project. If you're building an MVP or expect fewer than 1,000 orders per day, a simple Laravel Forge deployment with a managed database will serve you better.
However, if you're targeting:
- Multi-region deployment for global latency optimization
- Horizontal scaling during traffic spikes (Black Friday, flash sales)
- Zero-downtime deployments with canary releases
- Service isolation where payment processing, inventory, and order management scale independently
Then Kubernetes becomes the foundation that prevents costly rewrites later.
The Real-World Context
In 2023, I worked on migrating an e-commerce platform from a monolithic Laravel deployment on AWS EC2 to a Kubernetes-based architecture. The system handled approximately 15,000 orders daily with peaks reaching 3,000 orders per hour during promotional campaigns. The monolithic approach required vertical scaling (upgrading to larger EC2 instances), which meant:
- Downtime during instance resizing
- Over-provisioning for peak capacity (paying for unused resources 90% of the time)
- Database connection pool exhaustion when traffic spiked
- No ability to scale individual services (image processing always bottlenecked checkout)
The Kubernetes migration solved these issues, but it introduced operational complexity. This series documents the production-ready patterns we developed.
Architecture Overview
Our e-commerce platform follows a modular monolith approach—a single Laravel application organized into domain-driven modules that can later be extracted into microservices if needed. This is the sweet spot for most teams: you get the simplicity of a monolith with the organizational clarity of services.
High-Level Architecture Diagram
┌─────────────────────────────────────────────────────────────┐
│ Cloudflare CDN │
│ (Static Assets + DDoS Protection) │
└───────────────────────┬─────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ Kubernetes Ingress Controller │
│ (NGINX Ingress) │
└───────────┬─────────────────────────────────┬───────────────┘
│ │
▼ ▼
┌───────────────────────┐ ┌───────────────────────┐
│ Laravel API Pods │ │ Laravel Queue │
│ (Stateless) │ │ Workers (Stateless) │
│ • Product Catalog │ │ • Order Processing │
│ • Cart Management │ │ • Email Dispatch │
│ • Checkout API │ │ • Payment Webhooks │
└───────┬───────────────┘ └───────────┬───────────┘
│ │
└─────────────────┬───────────────────┘
│
┌─────────────────┼─────────────────┬──────────────┐
│ │ │ │
▼ ▼ ▼ ▼
┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────┐
│ PostgreSQL │ │ Redis │ │ Stripe │ │ S3 │
│ (Primary + │ │ (Sessions, │ │ Payment │ │ Product │
│ Replicas) │ │ Cache, │ │ Gateway │ │ Images │
│ │ │ Queues) │ │ │ │ │
└──────────────┘ └──────────────┘ └──────────────┘ └──────────┘
Service Breakdown
API Pods (Horizontal Scaling: 2-20 instances)
- Handles HTTP requests for browsing, cart, checkout
- Stateless design: no session data stored locally
- Autoscales based on CPU and request latency
Queue Worker Pods (Horizontal Scaling: 1-10 instances)
- Processes asynchronous jobs (order confirmation emails, inventory updates)
- Consumes Redis-backed Laravel queues
- Isolated from web traffic to prevent job delays during traffic spikes
Database Layer
- PostgreSQL for transactional data (orders, users, products)
- Read replicas for product catalog queries
- Redis for session storage, cache, and queue backend
External Services
- Stripe for payment processing (PCI compliance offloaded)
- S3 for product images and invoices
- Cloudflare for CDN and DDoS protection
Technology Stack Decisions
Here's what we're building with and why these specific choices matter:
| Component | Technology | Version | Why This Choice |
|---|---|---|---|
| Framework | Laravel | 12.x | Best-in-class ORM, queue system, and ecosystem. Version 12 includes native async support. |
| Language | PHP | 8.3+ | Required for Laravel 12. Opcache + JIT provides 15-30% performance gains over PHP 8.1. |
| Database | PostgreSQL | 16.x | JSONB support for flexible product attributes, superior concurrency control vs MySQL for high-write workloads. |
| Cache/Queue | Redis | 7.2+ | Persistence for queue jobs, sub-millisecond cache reads. |
| Payment | Stripe | API 2024-04-10 | Industry-leading developer experience, handles PCI compliance, supports 135+ currencies. |
| Container Runtime | Docker | 24.x+ | Development parity with production, layer caching speeds up builds. |
| Orchestration | Kubernetes | 1.28+ | EKS/GKE support, mature autoscaling, service mesh integration. |
| HTTP Server | PHP-FPM + NGINX | - | FPM process management prevents memory leaks, NGINX serves static assets directly. |
Alternative Approaches Considered
Why Not Node.js/NestJS? Laravel's Eloquent ORM and built-in payment abstractions would take months to replicate in Node. For teams already proficient in PHP, Laravel's productivity advantage is significant.
Why Not MySQL?
PostgreSQL's JSONB columns and partial indexes are critical for our product catalog, which needs flexible attributes (clothing has sizes, electronics have specs) without EAV anti-patterns.
Why Not Serverless (Lambda/Cloud Run)? Cold start latency (200-500ms) is unacceptable for checkout flows. Kubernetes provides predictable performance with warm instances.
Local Development Environment Setup
We'll create a development environment that mirrors production as closely as possible using Docker and Kubernetes (via Minikube or Docker Desktop's Kubernetes).
Prerequisites
Ensure you have these installed (check with the commands shown):
$ php --version
PHP 8.3.2 (cli) (built: Jan 16 2024 13:46:41) (NTS)
$ composer --version
Composer version 2.7.1 2024-02-09 15:26:28
$ docker --version
Docker version 24.0.7, build afdd53b
$ kubectl version --client
Client Version: v1.28.4
If any are missing:
# macOS
brew install php@8.3 composer docker docker-compose kubectl minikube
# Ubuntu/Debian
sudo apt update
sudo apt install -y php8.3-cli php8.3-fpm php8.3-pgsql php8.3-redis \
php8.3-mbstring php8.3-xml php8.3-curl composer docker.io
# Install kubectl
curl -LO "https://dl.k8s.io/release/v1.28.4/bin/linux/amd64/kubectl"
sudo install -o root -g root -m 0755 kubectl /usr/local/bin/kubectl
Creating the Laravel Project
We'll start with a fresh Laravel 12 installation. Important: Do not use Laravel Sail for this project—we're building custom Docker configurations optimized for production deployment.
# Create project directory
mkdir laravel-k8s-ecommerce && cd laravel-k8s-ecommerce
# Install Laravel 12 with Composer
composer create-project laravel/laravel:^12.0 .
# Verify installation
php artisan --version
# Output: Laravel Framework 12.0.0
Project Scaffolding and Structure
We'll organize code using a domain-driven structure that groups related functionality. This differs from Laravel's default structure and prevents the common "fat models, anemic controllers" anti-pattern.
Directory Structure
Create this structure in your Laravel project:
app/
├── Domain/
│ ├── Product/
│ │ ├── Models/
│ │ │ ├── Product.php
│ │ │ └── ProductVariant.php
│ │ ├── Services/
│ │ │ └── ProductCatalogService.php
│ │ ├── Repositories/
│ │ │ └── ProductRepository.php
│ │ └── Events/
│ │ └── ProductCreated.php
│ ├── Order/
│ │ ├── Models/
│ │ │ ├── Order.php
│ │ │ └── OrderItem.php
│ │ ├── Services/
│ │ │ ├── OrderService.php
│ │ │ └── PaymentService.php
│ │ └── Jobs/
│ │ └── ProcessOrderPayment.php
│ ├── Cart/
│ │ ├── Models/
│ │ │ └── Cart.php
│ │ └── Services/
│ │ └── CartService.php
│ └── User/
│ ├── Models/
│ │ └── User.php
│ └── Services/
│ └── CustomerService.php
├── Http/
│ └── Controllers/
│ └── Api/
│ ├── ProductController.php
│ ├── CartController.php
│ └── OrderController.php
└── Infrastructure/
├── Stripe/
│ └── StripePaymentGateway.php
└── Cache/
└── ProductCacheWarmer.php
Create the directory structure:
mkdir -p app/Domain/{Product,Order,Cart,User}/{Models,Services,Repositories,Events,Jobs}
mkdir -p app/Http/Controllers/Api
mkdir -p app/Infrastructure/{Stripe,Cache}
Configuration Management
Laravel's .env file is insufficient for production. We'll use a hybrid approach:
config/ecommerce.php (version-controlled defaults):
<?php
return [
/*
|--------------------------------------------------------------------------
| E-Commerce Platform Configuration
|--------------------------------------------------------------------------
|
| Core business logic configuration that should be version-controlled.
| Sensitive values (API keys) are still pulled from environment variables.
|
*/
'currency' => [
'default' => env('ECOMMERCE_CURRENCY', 'USD'),
'supported' => ['USD', 'EUR', 'GBP', 'CAD'],
],
'inventory' => [
// Reserve inventory when added to cart (prevents overselling)
'reserve_on_cart_add' => env('INVENTORY_RESERVE_ON_ADD', true),
// Minutes to hold inventory reservation
'reservation_ttl' => env('INVENTORY_RESERVATION_TTL', 15),
// Enable real-time inventory sync with external warehouse
'enable_warehouse_sync' => env('INVENTORY_WAREHOUSE_SYNC', false),
],
'checkout' => [
// Redirect to this URL after successful payment
'success_url' => env('CHECKOUT_SUCCESS_URL', '/order/confirmation'),
// Redirect to this URL on payment failure
'cancel_url' => env('CHECKOUT_CANCEL_URL', '/cart'),
// Maximum time (seconds) to complete checkout before session expires
'session_timeout' => env('CHECKOUT_SESSION_TIMEOUT', 1800),
],
'stripe' => [
'api_key' => env('STRIPE_SECRET_KEY'),
'publishable_key' => env('STRIPE_PUBLISHABLE_KEY'),
'webhook_secret' => env('STRIPE_WEBHOOK_SECRET'),
// Use Stripe test mode in non-production environments
'test_mode' => env('STRIPE_TEST_MODE', !app()->environment('production')),
// API version pinning for predictable behavior
'api_version' => '2024-04-10',
],
'performance' => [
// Cache product catalog queries (seconds)
'catalog_cache_ttl' => env('CATALOG_CACHE_TTL', 3600),
// Enable Redis-based session storage
'redis_sessions' => env('SESSION_DRIVER', 'redis') === 'redis',
// Maximum products per catalog page (prevent memory exhaustion)
'max_products_per_page' => 100,
],
];
Why this structure?
- Business logic defaults (currency, inventory rules) are version-controlled so all developers share the same config
- Sensitive values (API keys) still come from environment variables
- Configuration is typed and documented with comments
- Easy to override via environment variables in Kubernetes ConfigMaps
Database Architecture and Migrations
E-commerce databases have specific challenges: high-read product catalogs and high-write order processing. Our schema optimizes for both.
Environment Configuration
Update .env to use PostgreSQL:
DB_CONNECTION=pgsql
DB_HOST=127.0.0.1
DB_PORT=5432
DB_DATABASE=laravel_ecommerce
DB_USERNAME=laravel
DB_PASSWORD=secret
REDIS_HOST=127.0.0.1
REDIS_PASSWORD=null
REDIS_PORT=6379
REDIS_DB=0
CACHE_DRIVER=redis
QUEUE_CONNECTION=redis
SESSION_DRIVER=redis
Core Database Migrations
Products Migration (database/migrations/2024_01_01_000001_create_products_table.php):
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*
* Why these design choices:
* - JSONB for attributes: Flexible schema for different product types
* - Partial index on active products: 90% of queries filter by status
* - GIN index on attributes: Fast JSONB queries like "color = 'red'"
*/
public function up(): void
{
Schema::create('products', function (Blueprint $table) {
$table->id();
$table->string('sku', 100)->unique();
$table->string('name');
$table->text('description')->nullable();
$table->string('slug')->unique();
// Pricing stored in cents to avoid floating-point errors
// Example: $19.99 stored as 1999
$table->integer('price_cents')->unsigned();
$table->string('currency', 3)->default('USD');
// JSONB for flexible product attributes
// Example: {"color": "red", "size": "XL", "material": "cotton"}
$table->jsonb('attributes')->nullable();
// Inventory tracking
$table->integer('stock_quantity')->default(0);
$table->integer('reserved_quantity')->default(0);
// Soft deletes for order history preservation
$table->enum('status', ['draft', 'active', 'discontinued'])->default('draft');
$table->timestamps();
$table->softDeletes();
// Indexes
$table->index('status');
$table->index('slug');
});
// Create partial index for active products (PostgreSQL-specific)
// This index is much smaller and faster than indexing all products
DB::statement('CREATE INDEX products_active_idx ON products (status) WHERE status = \'active\'');
// GIN index for JSONB attribute queries
DB::statement('CREATE INDEX products_attributes_idx ON products USING GIN (attributes)');
}
public function down(): void
{
Schema::dropIfExists('products');
}
};
Orders Migration (database/migrations/2024_01_01_000002_create_orders_table.php):
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*
* Design notes:
* - Denormalized customer data: Prevents issues if user updates profile after order
* - Stripe payment intent stored: Enables refunds and payment tracking
* - Order items in separate table: Enables order-level promotions
*/
public function up(): void
{
Schema::create('orders', function (Blueprint $table) {
$table->id();
$table->string('order_number', 20)->unique();
// User relationship (nullable for guest checkout)
$table->foreignId('user_id')->nullable()->constrained()->nullOnDelete();
// Denormalized customer data (snapshot at order time)
$table->string('customer_email');
$table->string('customer_name');
$table->jsonb('shipping_address');
$table->jsonb('billing_address');
// Order totals (stored in cents)
$table->integer('subtotal_cents')->unsigned();
$table->integer('tax_cents')->unsigned()->default(0);
$table->integer('shipping_cents')->unsigned()->default(0);
$table->integer('discount_cents')->unsigned()->default(0);
$table->integer('total_cents')->unsigned();
$table->string('currency', 3);
// Payment tracking
$table->string('stripe_payment_intent_id')->nullable()->unique();
$table->enum('payment_status', [
'pending',
'processing',
'completed',
'failed',
'refunded'
])->default('pending');
// Order lifecycle
$table->enum('fulfillment_status', [
'unfulfilled',
'partially_fulfilled',
'fulfilled',
'shipped',
'delivered'
])->default('unfulfilled');
$table->timestamp('paid_at')->nullable();
$table->timestamp('fulfilled_at')->nullable();
$table->timestamp('shipped_at')->nullable();
$table->timestamps();
// Indexes for common queries
$table->index('order_number');
$table->index('customer_email');
$table->index('payment_status');
$table->index(['created_at', 'payment_status']); // Composite index for reporting
});
Schema::create('order_items', function (Blueprint $table) {
$table->id();
$table->foreignId('order_id')->constrained()->cascadeOnDelete();
$table->foreignId('product_id')->constrained()->restrictOnDelete();
// Snapshot product data at order time (price may change later)
$table->string('product_name');
$table->string('product_sku');
$table->jsonb('product_attributes')->nullable(); // Size, color, etc.
$table->integer('quantity')->unsigned();
$table->integer('unit_price_cents')->unsigned();
$table->integer('total_price_cents')->unsigned();
$table->timestamps();
$table->index('order_id');
});
}
public function down(): void
{
Schema::dropIfExists('order_items');
Schema::dropIfExists('orders');
}
};
Cart Migration (database/migrations/2024_01_01_000003_create_carts_table.php):
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Cart implementation strategy:
* - Redis for active sessions (fast, auto-expiration)
* - PostgreSQL for persistent carts (logged-in users, cart recovery)
*
* This migration creates the PostgreSQL persistent cart.
* Active session carts are stored in Redis with TTL.
*/
public function up(): void
{
Schema::create('carts', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->nullable()->constrained()->cascadeOnDelete();
$table->string('session_id')->nullable()->index();
// Cart metadata
$table->timestamp('last_activity_at')->useCurrent();
$table->timestamp('expires_at')->nullable();
$table->timestamps();
// Ensure one cart per user or session
$table->unique(['user_id']);
$table->unique(['session_id']);
});
Schema::create('cart_items', function (Blueprint $table) {
$table->id();
$table->foreignId('cart_id')->constrained()->cascadeOnDelete();
$table->foreignId('product_id')->constrained()->cascadeOnDelete();
$table->integer('quantity')->unsigned()->default(1);
// Store selected variant attributes (size, color, etc.)
$table->jsonb('selected_attributes')->nullable();
// Inventory reservation tracking
$table->string('reservation_id')->nullable()->unique();
$table->timestamp('reserved_until')->nullable();
$table->timestamps();
// Prevent duplicate products in same cart
$table->unique(['cart_id', 'product_id', 'selected_attributes']);
$table->index('reservation_id');
});
}
public function down(): void
{
Schema::dropIfExists('cart_items');
Schema::dropIfExists('carts');
}
};
Running Migrations
Start a local PostgreSQL instance with Docker and run migrations:
# Start PostgreSQL
docker run --name postgres-dev \
-e POSTGRES_DB=laravel_ecommerce \
-e POSTGRES_USER=laravel \
-e POSTGRES_PASSWORD=secret \
-p 5432:5432 \
-d postgres:16
# Start Redis
docker run --name redis-dev \
-p 6379:6379 \
-d redis:7.2
# Run migrations
php artisan migrate
# Expected output:
# 2024_01_01_000001_create_products_table .............. 142ms DONE
# 2024_01_01_000002_create_orders_table ................ 98ms DONE
# 2024_01_01_000003_create_carts_table ................. 76ms DONE
Verify the schema:
# Connect to PostgreSQL
docker exec -it postgres-dev psql -U laravel -d laravel_ecommerce
# List tables
\dt
# Output:
# Schema | Name | Type | Owner
# --------+-------------------+-------+---------
# public | carts | table | laravel
# public | cart_items | table | laravel
# public | orders | table | laravel
# public | order_items | table | laravel
# public | products | table | laravel
# Inspect indexes
\d products
# Verify partial index exists
SELECT indexdef FROM pg_indexes WHERE tablename = 'products' AND indexname = 'products_active_idx';
Docker Configuration for Local Development
We'll create a multi-stage Dockerfile that optimizes for both development (fast rebuilds) and production (minimal image size).
Project Dockerfile
Dockerfile (place in project root):
# syntax=docker/dockerfile:1.4
# ============================================================================
# Base Stage: Shared dependencies for all stages
# ============================================================================
FROM php:8.3-fpm-alpine AS base
# Install system dependencies and PHP extensions
# Why alpine? 5x smaller than debian-based images (40MB vs 200MB)
RUN apk add --no-cache \
postgresql-dev \
libzip-dev \
libpng-dev \
libjpeg-turbo-dev \
freetype-dev \
icu-dev \
oniguruma-dev \
&& docker-php-ext-configure gd --with-freetype --with-jpeg \
&& docker-php-ext-install -j$(nproc) \
pdo_pgsql \
pgsql \
zip \
gd \
intl \
mbstring \
opcache \
pcntl
# Install Redis extension via PECL
RUN apk add --no-cache --virtual .build-deps $PHPIZE_DEPS \
&& pecl install redis-6.0.2 \
&& docker-php-ext-enable redis \
&& apk del .build-deps
# Install Composer from official image
COPY --from=composer:2.7 /usr/bin/composer /usr/bin/composer
WORKDIR /var/www/html
# ============================================================================
# Development Stage: Hot-reloading, debugging tools
# ============================================================================
FROM base AS development
# Install Xdebug for step-debugging
RUN apk add --no-cache --virtual .build-deps $PHPIZE_DEPS \
&& pecl install xdebug-3.3.1 \
&& docker-php-ext-enable xdebug \
&& apk del .build-deps
# PHP configuration for development
COPY <<EOF /usr/local/etc/php/conf.d/99-development.ini
display_errors = On
display_startup_errors = On
error_reporting = E_ALL
xdebug.mode = debug
xdebug.start_with_request = yes
xdebug.client_host = host.docker.internal
xdebug.client_port = 9003
opcache.enable = 0
EOF
# Copy application code (will be overridden by volume mount in docker-compose)
COPY . .
# Install Composer dependencies with dev packages
RUN composer install --no-interaction --no-progress --optimize-autoloader
# Set permissions
RUN chown -R www-data:www-data /var/www/html/storage /var/www/html/bootstrap/cache
EXPOSE 9000
CMD ["php-fpm"]
# ============================================================================
# Production Dependencies Stage: Optimized vendor folder
# ============================================================================
FROM base AS production-dependencies
COPY composer.json composer.lock ./
# Install production dependencies only (no dev packages, optimized autoloader)
# --no-scripts prevents post-install scripts that require full app
RUN composer install \
--no-dev \
--no-interaction \
--no-progress \
--no-scripts \
--prefer-dist \
--optimize-autoloader \
--classmap-authoritative
# ============================================================================
# Production Assets Stage: Frontend build
# ============================================================================
FROM node:20-alpine AS production-assets
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --production=false
COPY resources ./resources
COPY vite.config.js tailwind.config.js postcss.config.js ./
RUN npm run build
# ============================================================================
# Production Stage: Minimal runtime image
# ============================================================================
FROM base AS production
# PHP configuration optimized for production
COPY <<EOF /usr/local/etc/php/conf.d/99-production.ini
; Error handling - log to stderr for container log aggregation
display_errors = Off
display_startup_errors = Off
log_errors = On
error_log = /proc/self/fd/2
; Opcache configuration for maximum performance
opcache.enable = 1
opcache.memory_consumption = 256
opcache.interned_strings_buffer = 16
opcache.max_accelerated_files = 20000
opcache.validate_timestamps = 0
opcache.save_comments = 1
opcache.fast_shutdown = 1
; Realpath cache (reduces filesystem lookups)
realpath_cache_size = 4096k
realpath_cache_ttl = 600
; Session configuration
session.save_handler = redis
session.save_path = "tcp://redis:6379"
; Security hardening
expose_php = Off
allow_url_fopen = Off
EOF
# Copy optimized dependencies from build stage
COPY --chown=www-data:www-data --from=production-dependencies /var/www/html/vendor ./vendor
# Copy application code
COPY --chown=www-data:www-data . .
# Copy built frontend assets
COPY --chown=www-data:www-data --from=production-assets /app/public/build ./public/build
# Generate optimized configuration cache
# Why: Reduces app boot time by ~30% in production
RUN php artisan config:cache \
&& php artisan route:cache \
&& php artisan view:cache
# Security: Run as non-root user
USER www-data
EXPOSE 9000
# Use exec form to ensure proper signal handling
CMD ["php-fpm"]
NGINX Configuration for Serving Laravel
docker/nginx/default.conf:
# nginx configuration optimized for Laravel + Kubernetes
server {
listen 80;
server_name _;
root /var/www/html/public;
index index.php;
charset utf-8;
# Security headers
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
# Kubernetes health check endpoint
# Responds instantly without hitting PHP-FPM
location /health {
access_log off;
return 200 "healthy\n";
add_header Content-Type text/plain;
}
# Serve static assets directly (bypass PHP-FPM)
location ~* \.(jpg|jpeg|png|gif|ico|css|js|svg|woff|woff2|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, immutable";
access_log off;
}
# Laravel application
location / {
try_files $uri $uri/ /index.php?$query_string;
}
# PHP-FPM processing
location ~ \.php$ {
fastcgi_pass laravel-app:9000;
fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
include fastcgi_params;
# Prevent caching of PHP responses
fastcgi_param HTTP_CACHE_CONTROL "no-store, no-cache, must-revalidate";
# Increase timeouts for long-running operations
fastcgi_read_timeout 300s;
# Hide PHP version in response headers
fastcgi_hide_header X-Powered-By;
}
# Block access to hidden files
location ~ /\. {
deny all;
access_log off;
log_not_found off;
}
# Custom error pages
error_page 404 /index.php;
}
Docker Compose for Local Development
docker-compose.yml:
version: '3.8'
services:
# Laravel PHP-FPM application
app:
build:
context: .
target: development
dockerfile: Dockerfile
container_name: laravel-app
volumes:
# Mount source code for hot-reloading
- .:/var/www/html
# Exclude vendor (use container's version for speed)
- /var/www/html/vendor
# Named volume for storage (persists uploads)
- storage-data:/var/www/html/storage/app
environment:
- APP_ENV=local
- APP_DEBUG=true
- DB_CONNECTION=pgsql
- DB_HOST=postgres
- DB_PORT=5432
- DB_DATABASE=laravel_ecommerce
- DB_USERNAME=laravel
- DB_PASSWORD=secret
- REDIS_HOST=redis
- REDIS_PORT=6379
- CACHE_DRIVER=redis
- QUEUE_CONNECTION=redis
- SESSION_DRIVER=redis
depends_on:
- postgres
- redis
networks:
- ecommerce-network
# NGINX web server
nginx:
image: nginx:1.25-alpine
container_name: laravel-nginx
ports:
- "8000:80"
volumes:
- .:/var/www/html:ro
- ./docker/nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
depends_on:
- app
networks:
- ecommerce-network
# PostgreSQL database
postgres:
image: postgres:16-alpine
container_name: laravel-postgres
environment:
POSTGRES_DB: laravel_ecommerce
POSTGRES_USER: laravel
POSTGRES_PASSWORD: secret
ports:
- "5432:5432"
volumes:
- postgres-data:/var/lib/postgresql/data
networks:
- ecommerce-network
# Redis cache and queue
redis:
image: redis:7.2-alpine
container_name: laravel-redis
ports:
- "6379:6379"
volumes:
- redis-data:/data
networks:
- ecommerce-network
# Queue worker for background jobs
queue-worker:
build:
context: .
target: development
dockerfile: Dockerfile
container_name: laravel-queue
command: php artisan queue:work --tries=3 --timeout=90 --sleep=3
volumes:
- .:/var/www/html
- /var/www/html/vendor
environment:
- APP_ENV=local
- DB_CONNECTION=pgsql
- DB_HOST=postgres
- REDIS_HOST=redis
- QUEUE_CONNECTION=redis
depends_on:
- postgres
- redis
networks:
- ecommerce-network
restart: unless-stopped
volumes:
postgres-data:
driver: local
redis-data:
driver: local
storage-data:
driver: local
networks:
ecommerce-network:
driver: bridge
Starting the Development Environment
# Build and start all services
docker-compose up -d --build
# Expected output:
# [+] Building 34.2s (19/19) FINISHED
# [+] Running 6/6
# ✔ Network laravel-k8s-ecommerce_ecommerce-network Created
# ✔ Container laravel-postgres Started
# ✔ Container laravel-redis Started
# ✔ Container laravel-app Started
# ✔ Container laravel-nginx Started
# ✔ Container laravel-queue Started
# Run migrations
docker-compose exec app php artisan migrate
# Check service status
docker-compose ps
# Output:
# NAME COMMAND SERVICE STATUS PORTS
# laravel-app "docker-php-entrypoi…" app running 9000/tcp
# laravel-nginx "/docker-entrypoint.…" nginx running 0.0.0.0:8000->80/tcp
# laravel-postgres "docker-entrypoint.s…" postgres running 0.0.0.0:5432->5432/tcp
# laravel-queue "docker-php-entrypoi…" queue running 9000/tcp
# laravel-redis "docker-entrypoint.s…" redis running 0.0.0.0:6379->6379/tcp
# Test the application
curl http://localhost:8000/health
# Expected output:
# healthy
First Working API Endpoint
Let's build a complete, production-ready endpoint for product catalog retrieval. This will demonstrate the entire stack working together.
Product Model
app/Domain/Product/Models/Product.php:
<?php
namespace App\Domain\Product\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
class Product extends Model
{
use HasFactory, SoftDeletes;
protected $fillable = [
'sku',
'name',
'description',
'slug',
'price_cents',
'currency',
'attributes',
'stock_quantity',
'reserved_quantity',
'status',
];
protected $casts = [
'price_cents' => 'integer',
'stock_quantity' => 'integer',
'reserved_quantity' => 'integer',
'attributes' => 'array', // Auto JSON encode/decode
'created_at' => 'datetime',
'updated_at' => 'datetime',
];
/**
* Get available stock (total - reserved).
*
* Why separate reserved_quantity?
* Prevents overselling when multiple customers add same item to cart.
*
* @return int
*/
public function getAvailableStockAttribute(): int
{
return max(0, $this->stock_quantity - $this->reserved_quantity);
}
/**
* Get price in decimal format (e.g., 19.99).
*
* Why store as cents?
* Avoids floating-point precision errors. Never use DECIMAL for money.
*
* @return float
*/
public function getPriceAttribute(): float
{
return $this->price_cents / 100;
}
/**
* Format price with currency symbol.
*
* @return string
*/
public function getFormattedPriceAttribute(): string
{
return match($this->currency) {
'USD' => '$' . number_format($this->price, 2),
'EUR' => '€' . number_format($this->price, 2),
'GBP' => '£' . number_format($this->price, 2),
default => $this->currency . ' ' . number_format($this->price, 2),
};
}
/**
* Scope query to only active products.
*
* @param \Illuminate\Database\Eloquent\Builder $query
* @return \Illuminate\Database\Eloquent\Builder
*/
public function scopeActive($query)
{
return $query->where('status', 'active');
}
/**
* Scope query to products with available stock.
*
* @param \Illuminate\Database\Eloquent\Builder $query
* @return \Illuminate\Database\Eloquent\Builder
*/
public function scopeInStock($query)
{
return $query->whereRaw('stock_quantity > reserved_quantity');
}
}
Product Repository
app/Domain/Product/Repositories/ProductRepository.php:
<?php
namespace App\Domain\Product\Repositories;
use App\Domain\Product\Models\Product;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
class ProductRepository
{
/**
* Get paginated active products with caching.
*
* Cache strategy:
* - Cache each page independently (cache key includes page number)
* - TTL from config (default 1 hour)
* - Cache tags for easy invalidation on product updates
*
* @param int $page
* @param int $perPage
* @param array $filters
* @return LengthAwarePaginator
*/
public function getPaginatedProducts(
int $page = 1,
int $perPage = 24,
array $filters = []
): LengthAwarePaginator {
$cacheKey = $this->getCacheKey('products.list', $page, $filters);
$cacheTtl = config('ecommerce.performance.catalog_cache_ttl', 3600);
return Cache::tags(['products'])->remember(
$cacheKey,
$cacheTtl,
fn() => $this->buildProductQuery($filters)
->paginate($perPage, ['*'], 'page', $page)
);
}
/**
* Build base query with filters.
*
* @param array $filters
* @return \Illuminate\Database\Eloquent\Builder
*/
protected function buildProductQuery(array $filters)
{
$query = Product::query()
->active()
->select([
'id',
'sku',
'name',
'slug',
'price_cents',
'currency',
'attributes',
'stock_quantity',
'reserved_quantity',
]);
// Filter by category (stored in attributes JSONB)
if (isset($filters['category'])) {
$query->whereRaw("attributes->>'category' = ?", [$filters['category']]);
}
// Filter by price range
if (isset($filters['min_price'])) {
$query->where('price_cents', '>=', $filters['min_price'] * 100);
}
if (isset($filters['max_price'])) {
$query->where('price_cents', '<=', $filters['max_price'] * 100);
}
// Filter by availability
if (isset($filters['in_stock']) && $filters['in_stock']) {
$query->inStock();
}
// Search by name or description
if (isset($filters['search'])) {
$search = $filters['search'];
$query->where(function($q) use ($search) {
$q->where('name', 'ILIKE', "%{$search}%")
->orWhere('description', 'ILIKE', "%{$search}%");
});
}
// Sorting
$sortBy = $filters['sort_by'] ?? 'created_at';
$sortOrder = $filters['sort_order'] ?? 'desc';
$allowedSorts = ['created_at', 'price_cents', 'name'];
if (in_array($sortBy, $allowedSorts)) {
$query->orderBy($sortBy, $sortOrder);
}
return $query;
}
/**
* Generate cache key from parameters.
*
* @param string $prefix
* @param int $page
* @param array $filters
* @return string
*/
protected function getCacheKey(string $prefix, int $page, array $filters): string
{
$filterHash = md5(json_encode($filters));
return "{$prefix}.page_{$page}.{$filterHash}";
}
/**
* Invalidate product cache.
* Call this when products are created/updated/deleted.
*
* @return bool
*/
public function clearCache(): bool
{
return Cache::tags(['products'])->flush();
}
}
API Controller
app/Http/Controllers/Api/ProductController.php:
<?php
namespace App\Http\Controllers\Api;
use App\Domain\Product\Repositories\ProductRepository;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Validator;
class ProductController extends Controller
{
public function __construct(
protected ProductRepository $productRepository
) {}
/**
* List products with pagination and filtering.
*
* GET /api/v1/products
*
* Query parameters:
* - page: Page number (default: 1)
* - per_page: Items per page (default: 24, max: 100)
* - category: Filter by category
* - min_price: Minimum price filter
* - max_price: Maximum price filter
* - in_stock: Only show available products (true/false)
* - search: Search in name and description
* - sort_by: Sort field (created_at, price_cents, name)
* - sort_order: Sort direction (asc, desc)
*
* @param Request $request
* @return JsonResponse
*/
public function index(Request $request): JsonResponse
{
// Validate query parameters
$validator = Validator::make($request->all(), [
'page' => 'integer|min:1',
'per_page' => 'integer|min:1|max:100',
'category' => 'string|max:50',
'min_price' => 'numeric|min:0',
'max_price' => 'numeric|min:0|gte:min_price',
'in_stock' => 'boolean',
'search' => 'string|max:200',
'sort_by' => 'in:created_at,price_cents,name',
'sort_order' => 'in:asc,desc',
]);
if ($validator->fails()) {
return response()->json([
'error' => 'Validation failed',
'messages' => $validator->errors(),
], 422);
}
try {
$validated = $validator->validated();
$page = $validated['page'] ?? 1;
$perPage = min($validated['per_page'] ?? 24, 100);
// Extract filters
$filters = array_intersect_key($validated, array_flip([
'category',
'min_price',
'max_price',
'in_stock',
'search',
'sort_by',
'sort_order',
]));
$products = $this->productRepository->getPaginatedProducts(
$page,
$perPage,
$filters
);
// Transform response
return response()->json([
'data' => $products->map(fn($product) => [
'id' => $product->id,
'sku' => $product->sku,
'name' => $product->name,
'slug' => $product->slug,
'price' => [
'amount' => $product->price,
'formatted' => $product->formatted_price,
'currency' => $product->currency,
],
'attributes' => $product->attributes,
'availability' => [
'in_stock' => $product->available_stock > 0,
'quantity' => $product->available_stock,
],
]),
'meta' => [
'current_page' => $products->currentPage(),
'last_page' => $products->lastPage(),
'per_page' => $products->perPage(),
'total' => $products->total(),
],
'links' => [
'first' => $products->url(1),
'last' => $products->url($products->lastPage()),
'prev' => $products->previousPageUrl(),
'next' => $products->nextPageUrl(),
],
], 200);
} catch (\Exception $e) {
// Log error with context for debugging
Log::error('Product listing failed', [
'error' => $e->getMessage(),
'trace' => $e->getTraceAsString(),
'filters' => $filters ?? [],
]);
return response()->json([
'error' => 'Failed to retrieve products',
'message' => config('app.debug') ? $e->getMessage() : 'Internal server error',
], 500);
}
}
}
API Routes
routes/api.php:
<?php
use App\Http\Controllers\Api\ProductController;
use Illuminate\Support\Facades\Route;
/*
|--------------------------------------------------------------------------
| API Routes
|--------------------------------------------------------------------------
|
| API versioning strategy: /api/v1/...
| This allows breaking changes in future versions without affecting existing clients.
|
*/
Route::prefix('v1')->group(function () {
// Product catalog endpoints
Route::get('/products', [ProductController::class, 'index'])
->name('api.v1.products.index');
// Add rate limiting middleware for production
// Route::middleware('throttle:60,1')->group(function () {
// Route::get('/products', [ProductController::class, 'index']);
// });
});
Seeding Test Data
database/seeders/ProductSeeder.php:
<?php
namespace Database\Seeders;
use App\Domain\Product\Models\Product;
use Illuminate\Database\Seeder;
class ProductSeeder extends Seeder
{
/**
* Seed product catalog with realistic data.
*/
public function run(): void
{
$products = [
[
'sku' => 'TSHIRT-BLK-M',
'name' => 'Premium Cotton T-Shirt - Black',
'description' => 'Soft, breathable cotton t-shirt perfect for everyday wear.',
'slug' => 'premium-cotton-tshirt-black',
'price_cents' => 2999, // $29.99
'currency' => 'USD',
'attributes' => [
'category' => 'clothing',
'color' => 'black',
'size' => 'M',
'material' => 'cotton',
],
'stock_quantity' => 150,
'reserved_quantity' => 5,
'status' => 'active',
],
[
'sku' => 'JEANS-DNM-32',
'name' => 'Classic Denim Jeans',
'description' => 'Timeless denim jeans with a comfortable fit.',
'slug' => 'classic-denim-jeans',
'price_cents' => 7999, // $79.99
'currency' => 'USD',
'attributes' => [
'category' => 'clothing',
'color' => 'blue',
'size' => '32',
'material' => 'denim',
],
'stock_quantity' => 85,
'reserved_quantity' => 0,
'status' => 'active',
],
[
'sku' => 'SNKR-RUN-10',
'name' => 'Performance Running Shoes',
'description' => 'Lightweight running shoes designed for speed and comfort.',
'slug' => 'performance-running-shoes',
'price_cents' => 12999, // $129.99
'currency' => 'USD',
'attributes' => [
'category' => 'footwear',
'color' => 'white',
'size' => '10',
'material' => 'mesh',
],
'stock_quantity' => 42,
'reserved_quantity' => 3,
'status' => 'active',
],
];
foreach ($products as $productData) {
Product::create($productData);
}
$this->command->info('Seeded ' . count($products) . ' products.');
}
}
Run the seeder:
docker-compose exec app php artisan db:seed --class=ProductSeeder
Testing the API
# Test product listing
curl -X GET "http://localhost:8000/api/v1/products" \
-H "Accept: application/json" | jq
# Expected output:
# {
# "data": [
# {
# "id": 1,
# "sku": "TSHIRT-BLK-M",
# "name": "Premium Cotton T-Shirt - Black",
# "slug": "premium-cotton-tshirt-black",
# "price": {
# "amount": 29.99,
# "formatted": "$29.99",
# "currency": "USD"
# },
# "attributes": {
# "category": "clothing",
# "color": "black",
# "size": "M",
# "material": "cotton"
# },
# "availability": {
# "in_stock": true,
# "quantity": 145
# }
# },
# ...
# ],
# "meta": {
# "current_page": 1,
# "last_page": 1,
# "per_page": 24,
# "total": 3
# }
# }
# Test filtering by category
curl -X GET "http://localhost:8000/api/v1/products?category=clothing&in_stock=true" \
-H "Accept: application/json" | jq
# Test price range filtering
curl -X GET "http://localhost:8000/api/v1/products?min_price=50&max_price=100" \
-H "Accept: application/json" | jq
# Test search
curl -X GET "http://localhost:8000/api/v1/products?search=running" \
-H "Accept: application/json" | jq
# Test pagination
curl -X GET "http://localhost:8000/api/v1/products?page=1&per_page=2" \
-H "Accept: application/json" | jq
Verifying Cache Behavior
# Check Redis for cached queries
docker-compose exec redis redis-cli
# List all keys
KEYS *
# Output should include cache keys like:
# laravel_database_products:products.list.page_1.abc123...
# Check cache hit by querying same endpoint twice and comparing response times
time curl -s "http://localhost:8000/api/v1/products" > /dev/null
# First request: ~150ms (database query)
time curl -s "http://localhost:8000/api/v1/products" > /dev/null
# Second request: ~5ms (cache hit)
Common Setup Mistakes and Solutions
1. Using SQLite or MySQL in Development, PostgreSQL in Production
The Problem: Feature parity issues. PostgreSQL's JSONB type, full-text search, and advanced indexing don't have MySQL equivalents.
The Solution: Use PostgreSQL everywhere. Docker makes this trivial.
2. Not Pinning Dependency Versions
The Problem: composer install on different machines pulls different package versions, causing "works on my machine" bugs.
The Solution:
# Always commit composer.lock
git add composer.lock
# Use exact versions in production
composer install --no-dev --optimize-autoloader
3. Storing Money as DECIMAL or FLOAT
The Problem: Floating-point precision errors. 19.99 * 100 may equal 1998.9999999 in some cases.
The Solution: Always store money as integers in the smallest currency unit (cents, pence, etc.). Our price_cents column avoids this entirely.
4. Not Handling Database Connection Failures
The Problem: In Kubernetes, database pods restart during updates. Unhandled connection failures crash your app.
The Solution: Add retry logic in config/database.php:
'pgsql' => [
'driver' => 'pgsql',
'host' => env('DB_HOST', '127.0.0.1'),
'port' => env('DB_PORT', '5432'),
'database' => env('DB_DATABASE', 'forge'),
'username' => env('DB_USERNAME', 'forge'),
'password' => env('DB_PASSWORD', ''),
'charset' => 'utf8',
'prefix' => '',
'prefix_indexes' => true,
'search_path' => 'public',
'sslmode' => 'prefer',
// Retry failed connections (critical for Kubernetes)
'options' => [
PDO::ATTR_TIMEOUT => 5,
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_PERSISTENT => false,
],
],
5. Ignoring Opcache Configuration
The Problem: Default PHP configuration rechecks file modification times on every request, killing performance.
The Solution: Our production Dockerfile sets opcache.validate_timestamps=0. After deployment, Opcache never checks if files changed (because they won't—containers are immutable).
6. Not Using Cache Tags
The Problem: Cache invalidation is hard. Clearing all cache when one product updates is wasteful.
The Solution: Cache tags (requires Redis or Memcached). Our ProductRepository uses Cache::tags(['products']) so we can invalidate only product-related caches:
// Clear only product caches, leave session and other caches intact
Cache::tags(['products'])->flush();
What's Next in Part 2: Stripe Payment Integration
In the next part of this series, we'll integrate Stripe for payment processing with:
- Payment Intent API for secure checkout flows
- Webhook handling for asynchronous payment confirmations
- Idempotency keys to prevent duplicate charges
- 3D Secure (SCA) compliance for European customers
- Refund and dispute handling workflows
- Testing with Stripe CLI for local webhook simulation
We'll also implement the OrderService that coordinates inventory reservation, payment processing, and order creation in a single atomic transaction.
Preview: OrderService Transaction Pattern
DB::transaction(function () use ($cart, $paymentIntentId) {
// 1. Convert cart to order
$order = $this->orderRepository->createFromCart($cart);
// 2. Confirm Stripe payment
$payment = $this->paymentService->confirmPayment($paymentIntentId);
// 3. Reduce inventory (only if payment succeeds)
$this->inventoryService->decrementStock($order);
// 4. Clear cart
$cart->delete();
// 5. Dispatch order confirmation email job
dispatch(new SendOrderConfirmation($order));
});
All of this with proper error handling, retry logic, and webhook verification.
Summary: What We Built
In this first part, we established the foundation for a production-grade e-commerce platform:
✅ Architecture design that scales horizontally on Kubernetes
✅ Docker multi-stage builds optimized for development and production
✅ PostgreSQL schema with JSONB for flexible product attributes
✅ Repository pattern with Redis caching for performance
✅ Complete API endpoint with filtering, pagination, and validation
✅ Development environment with Docker Compose for team consistency
Key Takeaways:
- Start with production architecture from day one. Migrating a monolith to Kubernetes later is painful.
- Use PostgreSQL's advanced features. JSONB and partial indexes are game-changers for e-commerce.
- Cache aggressively with tags. Product catalogs change infrequently—exploit that.
- Store money as integers. Never use floats for currency.
- Make development match production. Docker ensures everyone runs the same stack.
Clone the complete code from this tutorial at https://github.com/iBekzod/laravel-k8s-ecommerce and follow along with Part 2!
Read the next article: Part 2: Stripe Payment Integration & Order Processing (coming soon)
Questions or feedback? Open an issue on the GitHub repository or reach out at 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