Horizontal Scaling Laravel: Guide to Scaling Your App to Millions of Users
·
10 min read
Introduction
As your Laravel application grows, a single server won't be enough to handle the load. Horizontal scaling (scale out) is the strategy of adding more servers to distribute the load, instead of upgrading the hardware of the existing server (vertical scaling).
Vertical vs Horizontal Scaling
Vertical Scaling (Scale Up):
┌─────────────────┐
│ Bigger Server │
│ More CPU/RAM │
│ Single Point │
│ of Failure │
└─────────────────┘
Horizontal Scaling (Scale Out):
┌────────┐ ┌────────┐ ┌────────┐
│Server 1│ │Server 2│ │Server 3│
└────────┘ └────────┘ └────────┘
│ │ │
└──────────┼──────────┘
Load Balancer
Challenges When Horizontal Scaling
- Session Management: Sessions must be shared between servers
- File Storage: Uploaded files need to be accessible from all servers
- Cache Consistency: Cache must be synchronized
- Queue Processing: Jobs need to be distributed evenly
- Database Connections: Connection pooling and replication
Architecture Overview
┌─────────────────┐
│ CloudFlare │
│ (CDN + WAF) │
└────────┬────────┘
│
┌────────▼────────┐
│ Load Balancer │
│ (AWS ALB) │
└────────┬────────┘
│
┌────────────────────┼────────────────────┐
│ │ │
┌───────▼───────┐ ┌───────▼───────┐ ┌───────▼───────┐
│ App Server │ │ App Server │ │ App Server │
│ (Laravel) │ │ (Laravel) │ │ (Laravel) │
└───────┬───────┘ └───────┬───────┘ └───────┬───────┘
│ │ │
└────────────────────┼────────────────────┘
│
┌────────────────────┼────────────────────┐
│ │ │
┌───────▼───────┐ ┌───────▼───────┐ ┌───────▼───────┐
│ Redis │ │ MySQL │ │ S3 │
│ (Session) │ │ (Primary) │ │ (Files) │
│ (Cache) │ │ + Replicas │ │ │
│ (Queue) │ │ │ │ │
└───────────────┘ └───────────────┘ └───────────────┘
1. Load Balancing
AWS Application Load Balancer
# terraform/alb.tf
resource "aws_lb" "main" {
name = "laravel-alb"
internal = false
load_balancer_type = "application"
security_groups = [aws_security_group.alb.id]
subnets = aws_subnet.public[*].id
enable_deletion_protection = true
tags = {
Environment = "production"
}
}
resource "aws_lb_target_group" "app" {
name = "laravel-tg"
port = 80
protocol = "HTTP"
vpc_id = aws_vpc.main.id
health_check {
enabled = true
healthy_threshold = 2
interval = 30
matcher = "200"
path = "/health"
port = "traffic-port"
protocol = "HTTP"
timeout = 5
unhealthy_threshold = 2
}
stickiness {
type = "lb_cookie"
cookie_duration = 86400
enabled = false # Disabled because we use Redis sessions
}
}
resource "aws_lb_listener" "https" {
load_balancer_arn = aws_lb.main.arn
port = "443"
protocol = "HTTPS"
ssl_policy = "ELBSecurityPolicy-TLS13-1-2-2021-06"
certificate_arn = aws_acm_certificate.main.arn
default_action {
type = "forward"
target_group_arn = aws_lb_target_group.app.arn
}
}
Health Check Endpoint
// routes/web.php
Route::get('/health', function () {
try {
// Check database
DB::connection()->getPdo();
// Check Redis
Cache::store('redis')->put('health_check', true, 10);
// Check queue
Queue::size('default');
return response()->json([
'status' => 'healthy',
'timestamp' => now()->toIso8601String(),
'server' => gethostname(),
]);
} catch (\Exception $e) {
return response()->json([
'status' => 'unhealthy',
'error' => $e->getMessage(),
], 503);
}
});
Nginx Configuration
# /etc/nginx/conf.d/laravel.conf
upstream laravel_backend {
least_conn; # Load balancing algorithm
server app1.internal:9000 weight=5;
server app2.internal:9000 weight=5;
server app3.internal:9000 weight=5;
keepalive 32;
}
server {
listen 80;
server_name example.com;
root /var/www/html/public;
index index.php;
# Real IP from load balancer
set_real_ip_from 10.0.0.0/8;
real_ip_header X-Forwarded-For;
location / {
try_files $uri $uri/ /index.php?$query_string;
}
location ~ \.php$ {
fastcgi_pass laravel_backend;
fastcgi_index index.php;
fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
include fastcgi_params;
# Timeout settings
fastcgi_connect_timeout 60s;
fastcgi_send_timeout 60s;
fastcgi_read_timeout 60s;
}
}
2. Session Management
Redis Session Driver
// config/session.php
return [
'driver' => env('SESSION_DRIVER', 'redis'),
'connection' => env('SESSION_CONNECTION', 'session'),
'lifetime' => env('SESSION_LIFETIME', 120),
'expire_on_close' => false,
'encrypt' => true,
'lottery' => [2, 100],
'cookie' => env('SESSION_COOKIE', 'laravel_session'),
'path' => '/',
'domain' => env('SESSION_DOMAIN'),
'secure' => env('SESSION_SECURE_COOKIE', true),
'http_only' => true,
'same_site' => 'lax',
];
Redis Configuration
// config/database.php
'redis' => [
'client' => env('REDIS_CLIENT', 'phpredis'),
'options' => [
'cluster' => env('REDIS_CLUSTER', 'redis'),
'prefix' => env('REDIS_PREFIX', 'laravel:'),
],
// Dedicated connection for sessions
'session' => [
'host' => env('REDIS_SESSION_HOST', '127.0.0.1'),
'password' => env('REDIS_SESSION_PASSWORD'),
'port' => env('REDIS_SESSION_PORT', '6379'),
'database' => env('REDIS_SESSION_DB', '1'),
],
// Dedicated connection for cache
'cache' => [
'host' => env('REDIS_CACHE_HOST', '127.0.0.1'),
'password' => env('REDIS_CACHE_PASSWORD'),
'port' => env('REDIS_CACHE_PORT', '6379'),
'database' => env('REDIS_CACHE_DB', '2'),
],
// Dedicated connection for queues
'queue' => [
'host' => env('REDIS_QUEUE_HOST', '127.0.0.1'),
'password' => env('REDIS_QUEUE_PASSWORD'),
'port' => env('REDIS_QUEUE_PORT', '6379'),
'database' => env('REDIS_QUEUE_DB', '3'),
],
],
Redis Cluster for High Availability
// config/database.php - Redis Cluster
'redis' => [
'client' => env('REDIS_CLIENT', 'phpredis'),
'clusters' => [
'default' => [
[
'host' => env('REDIS_HOST_1', '10.0.1.10'),
'password' => env('REDIS_PASSWORD'),
'port' => env('REDIS_PORT', 6379),
],
[
'host' => env('REDIS_HOST_2', '10.0.1.11'),
'password' => env('REDIS_PASSWORD'),
'port' => env('REDIS_PORT', 6379),
],
[
'host' => env('REDIS_HOST_3', '10.0.1.12'),
'password' => env('REDIS_PASSWORD'),
'port' => env('REDIS_PORT', 6379),
],
],
],
'options' => [
'cluster' => env('REDIS_CLUSTER', 'redis'),
],
],
3. Distributed File Storage
AWS S3 Configuration
// config/filesystems.php
'disks' => [
's3' => [
'driver' => 's3',
'key' => env('AWS_ACCESS_KEY_ID'),
'secret' => env('AWS_SECRET_ACCESS_KEY'),
'region' => env('AWS_DEFAULT_REGION'),
'bucket' => env('AWS_BUCKET'),
'url' => env('AWS_URL'),
'endpoint' => env('AWS_ENDPOINT'),
'use_path_style_endpoint' => env('AWS_USE_PATH_STYLE_ENDPOINT', false),
'throw' => true,
'cdn' => env('AWS_CLOUDFRONT_URL'),
],
],
File Upload Service
// app/Services/FileUploadService.php
namespace App\Services;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
class FileUploadService
{
public function upload(
UploadedFile $file,
string $directory = 'uploads',
?string $visibility = 'public'
): array {
$filename = $this->generateFilename($file);
$path = "{$directory}/{$filename}";
Storage::disk('s3')->put($path, $file->getContent(), [
'visibility' => $visibility,
'ContentType' => $file->getMimeType(),
'CacheControl' => 'max-age=31536000',
]);
return [
'path' => $path,
'url' => $this->getUrl($path),
'cdn_url' => $this->getCdnUrl($path),
'size' => $file->getSize(),
'mime_type' => $file->getMimeType(),
];
}
public function delete(string $path): bool
{
return Storage::disk('s3')->delete($path);
}
private function generateFilename(UploadedFile $file): string
{
$extension = $file->getClientOriginalExtension();
$hash = Str::random(32);
$date = now()->format('Y/m/d');
return "{$date}/{$hash}.{$extension}";
}
private function getUrl(string $path): string
{
return Storage::disk('s3')->url($path);
}
private function getCdnUrl(string $path): string
{
$cdnUrl = config('filesystems.disks.s3.cdn');
return $cdnUrl ? "{$cdnUrl}/{$path}" : $this->getUrl($path);
}
}
Temporary URLs for Private Files
// Create time-limited URL for private files
$url = Storage::disk('s3')->temporaryUrl(
'private/document.pdf',
now()->addMinutes(30),
[
'ResponseContentDisposition' => 'attachment; filename="document.pdf"',
]
);
4. Distributed Caching
Cache Tagging for Invalidation
// app/Services/ProductCacheService.php
namespace App\Services;
use App\Models\Product;
use Illuminate\Support\Facades\Cache;
class ProductCacheService
{
private const TTL = 3600;
public function get(int $id): ?Product
{
return Cache::tags(['products', "product:{$id}"])
->remember("product:{$id}", self::TTL, function () use ($id) {
return Product::with(['category', 'variants'])->find($id);
});
}
public function getByCategory(int $categoryId): array
{
return Cache::tags(['products', "category:{$categoryId}"])
->remember("category:{$categoryId}:products", self::TTL, function () use ($categoryId) {
return Product::where('category_id', $categoryId)
->active()
->get()
->toArray();
});
}
public function invalidateProduct(int $id): void
{
Cache::tags(["product:{$id}"])->flush();
}
public function invalidateCategory(int $categoryId): void
{
Cache::tags(["category:{$categoryId}"])->flush();
}
public function invalidateAll(): void
{
Cache::tags(['products'])->flush();
}
}
Distributed Locks
use Illuminate\Support\Facades\Cache;
// Atomic lock to avoid race conditions
$lock = Cache::lock('processing:order:123', 10);
if ($lock->get()) {
try {
$this->processOrder($order);
} finally {
$lock->release();
}
} else {
throw new OrderBeingProcessedException();
}
// Block until lock available
$lock = Cache::lock('processing:report', 60);
$lock->block(30, function () {
$this->generateReport();
});
5. Queue Workers Scaling
Supervisor Configuration
; /etc/supervisor/conf.d/laravel-worker.conf
[program:laravel-worker-default]
process_name=%(program_name)s_%(process_num)02d
command=php /var/www/html/artisan queue:work redis --queue=default --sleep=3 --tries=3 --max-time=3600
autostart=true
autorestart=true
stopasgroup=true
killasgroup=true
user=www-data
numprocs=8
redirect_stderr=true
stdout_logfile=/var/log/laravel/worker-default.log
stopwaitsecs=3600
[program:laravel-worker-high]
process_name=%(program_name)s_%(process_num)02d
command=php /var/www/html/artisan queue:work redis --queue=high --sleep=1 --tries=3 --max-time=3600
autostart=true
autorestart=true
user=www-data
numprocs=4
redirect_stderr=true
stdout_logfile=/var/log/laravel/worker-high.log
[program:laravel-worker-low]
process_name=%(program_name)s_%(process_num)02d
command=php /var/www/html/artisan queue:work redis --queue=low --sleep=5 --tries=3 --max-time=3600
autostart=true
autorestart=true
user=www-data
numprocs=2
redirect_stderr=true
stdout_logfile=/var/log/laravel/worker-low.log
Laravel Horizon Configuration
// config/horizon.php
return [
'environments' => [
'production' => [
'supervisor-high' => [
'connection' => 'redis',
'queue' => ['high'],
'balance' => 'auto',
'autoScalingStrategy' => 'time',
'maxProcesses' => 10,
'maxTime' => 3600,
'memory' => 256,
'tries' => 3,
'timeout' => 300,
],
'supervisor-default' => [
'connection' => 'redis',
'queue' => ['default'],
'balance' => 'auto',
'autoScalingStrategy' => 'time',
'maxProcesses' => 20,
'maxTime' => 3600,
'memory' => 256,
'tries' => 3,
'timeout' => 300,
],
'supervisor-low' => [
'connection' => 'redis',
'queue' => ['low'],
'balance' => 'auto',
'autoScalingStrategy' => 'size',
'maxProcesses' => 5,
'maxTime' => 3600,
'memory' => 256,
'tries' => 3,
'timeout' => 600,
],
],
],
];
6. Database Scaling
Read/Write Splitting
// config/database.php
'mysql' => [
'read' => [
'host' => [
env('DB_READ_HOST_1', '10.0.1.20'),
env('DB_READ_HOST_2', '10.0.1.21'),
env('DB_READ_HOST_3', '10.0.1.22'),
],
],
'write' => [
'host' => env('DB_WRITE_HOST', '10.0.1.10'),
],
'sticky' => true, // Use write connection for reads after write
'driver' => 'mysql',
'database' => env('DB_DATABASE', 'laravel'),
'username' => env('DB_USERNAME', 'root'),
'password' => env('DB_PASSWORD', ''),
'charset' => 'utf8mb4',
'collation' => 'utf8mb4_unicode_ci',
'prefix' => '',
'strict' => true,
],
Force Write Connection
// Force read from write connection when latest data is needed
$user = User::onWriteConnection()->find($id);
// Or within transaction
DB::transaction(function () {
$order = Order::create([...]);
$order->items()->createMany([...]);
});
7. Auto Scaling
AWS Auto Scaling Group
resource "aws_autoscaling_group" "app" {
name = "laravel-asg"
vpc_zone_identifier = aws_subnet.private[*].id
target_group_arns = [aws_lb_target_group.app.arn]
health_check_type = "ELB"
min_size = 2
max_size = 10
desired_capacity = 3
launch_template {
id = aws_launch_template.app.id
version = "$Latest"
}
instance_refresh {
strategy = "Rolling"
preferences {
min_healthy_percentage = 75
}
}
}
resource "aws_autoscaling_policy" "scale_up" {
name = "scale-up"
scaling_adjustment = 2
adjustment_type = "ChangeInCapacity"
cooldown = 300
autoscaling_group_name = aws_autoscaling_group.app.name
}
resource "aws_cloudwatch_metric_alarm" "high_cpu" {
alarm_name = "high-cpu"
comparison_operator = "GreaterThanThreshold"
evaluation_periods = "2"
metric_name = "CPUUtilization"
namespace = "AWS/EC2"
period = "120"
statistic = "Average"
threshold = "70"
alarm_actions = [aws_autoscaling_policy.scale_up.arn]
dimensions = {
AutoScalingGroupName = aws_autoscaling_group.app.name
}
}
8. Monitoring with Laravel Pulse
// config/pulse.php
return [
'enabled' => env('PULSE_ENABLED', true),
'recorders' => [
Recorders\CacheInteractions::class => [
'enabled' => true,
'sample_rate' => 0.1,
],
Recorders\Exceptions::class => [
'enabled' => true,
],
Recorders\Queues::class => [
'enabled' => true,
],
Recorders\SlowQueries::class => [
'enabled' => true,
'threshold' => 100, // ms
],
Recorders\SlowRequests::class => [
'enabled' => true,
'threshold' => 1000, // ms
],
Recorders\Servers::class => [
'enabled' => true,
],
],
];
Conclusion
Pre-Scale Checklist
- Sessions using Redis/Database
- Files uploaded to S3
- Cache using Redis
- Queues using Redis with Horizon
- Health check endpoint working
- Environment variables from SSM/Secrets Manager
- Centralized logging (CloudWatch/ELK)
- Monitoring and alerting set up