Introduction
Scaling a Laravel SaaS application is one of the most critical challenges you'll face as your user base grows. What works perfectly for 100 users can crumble under the load of 10,000. The difference between a successful SaaS platform and one that fails often comes down to how well it scales.
Laravel 12 provides powerful tools for building scalable applications, but knowing how to use them effectively is key. From intelligent caching strategies to queue optimization, database tuning, and horizontal scaling, this guide covers everything you need to scale your Laravel SaaS to handle millions of requests.
Whether you're preparing for exponential growth or already experiencing performance bottlenecks, this comprehensive guide will help you build a Laravel application that scales efficiently and cost-effectively.
Understanding Laravel Application Scaling
Before diving into implementation, it's crucial to understand the fundamental concepts of application scaling and identify where bottlenecks typically occur.
What is Application Scaling?
Scaling is the process of adapting your application infrastructure to handle increased load while maintaining performance and reliability. There are two primary approaches:
Vertical Scaling (Scaling Up)
- Adding more resources (CPU, RAM, storage) to existing servers
- Simpler to implement initially
- Has physical and cost limitations
- Eventual diminishing returns
Horizontal Scaling (Scaling Out)
- Adding more servers to distribute load
- Better long-term scalability
- Requires architectural planning
- More complex but more resilient
Common Performance Bottlenecks in Laravel SaaS
Understanding where your application slows down is the first step to fixing it:
Database Queries
- N+1 query problems
- Missing indexes
- Inefficient queries
- Too many joins
- Large result sets without pagination
Session Management
- File-based sessions don't scale
- Database sessions can become bottlenecks
- Need distributed session storage
File Storage
- Local disk storage doesn't scale horizontally
- Large file uploads blocking requests
- No CDN for static assets
Synchronous Operations
- Email sending in request cycle
- External API calls blocking responses
- Heavy computational tasks
- Report generation during requests
Cache Misses
- Expensive queries executed repeatedly
- No caching strategy
- Cache invalidation problems
Redis Caching Strategies for Laravel
Redis is the gold standard for Laravel caching. It's fast, supports advanced data structures, and scales horizontally.
Setting Up Redis in Laravel 12
# Install Redis
sudo apt-get install redis-server
# Install PHP Redis extension
sudo pecl install redis
# Or use predis (pure PHP)
composer require predis/predis
// config/database.php
'redis' => [
'client' => env('REDIS_CLIENT', 'phpredis'),
'options' => [
'cluster' => env('REDIS_CLUSTER', 'redis'),
'prefix' => env('REDIS_PREFIX', Str::slug(env('APP_NAME', 'laravel'), '_').'_database_'),
],
'default' => [
'url' => env('REDIS_URL'),
'host' => env('REDIS_HOST', '127.0.0.1'),
'username' => env('REDIS_USERNAME'),
'password' => env('REDIS_PASSWORD'),
'port' => env('REDIS_PORT', '6379'),
'database' => env('REDIS_DB', '0'),
],
'cache' => [
'url' => env('REDIS_URL'),
'host' => env('REDIS_HOST', '127.0.0.1'),
'username' => env('REDIS_USERNAME'),
'password' => env('REDIS_PASSWORD'),
'port' => env('REDIS_PORT', '6379'),
'database' => env('REDIS_CACHE_DB', '1'),
],
],
// config/cache.php
'default' => env('CACHE_DRIVER', 'redis'),
'stores' => [
'redis' => [
'driver' => 'redis',
'connection' => 'cache',
'lock_connection' => 'default',
],
],
Intelligent Query Caching
namespace App\Services;
use Illuminate\Support\Facades\Cache;
use App\Models\Product;
class ProductService
{
/**
* Cache expensive queries with automatic invalidation
*/
public function getFeaturedProducts(): Collection
{
return Cache::tags(['products', 'featured'])
->remember('products:featured', 3600, function () {
return Product::with(['category', 'images'])
->where('is_featured', true)
->where('is_active', true)
->orderBy('featured_order')
->get();
});
}
/**
* Cache with tenant isolation
*/
public function getProductsByTenant(int $tenantId): Collection
{
$cacheKey = "tenant:{$tenantId}:products";
return Cache::tags(['products', "tenant:{$tenantId}"])
->remember($cacheKey, 1800, function () use ($tenantId) {
return Product::where('tenant_id', $tenantId)
->with('category')
->latest()
->get();
});
}
/**
* Cache expensive aggregations
*/
public function getDashboardStats(int $tenantId): array
{
return Cache::tags(["tenant:{$tenantId}", 'stats'])
->remember("tenant:{$tenantId}:dashboard", 600, function () use ($tenantId) {
return [
'total_revenue' => Order::where('tenant_id', $tenantId)
->where('status', 'completed')
->sum('total'),
'total_orders' => Order::where('tenant_id', $tenantId)->count(),
'active_users' => User::where('tenant_id', $tenantId)
->where('last_login_at', '>=', now()->subDays(30))
->count(),
'pending_orders' => Order::where('tenant_id', $tenantId)
->where('status', 'pending')
->count(),
];
});
}
/**
* Invalidate cache when data changes
*/
public function updateProduct(Product $product, array $data): Product
{
$product->update($data);
// Clear related caches
Cache::tags(['products', "tenant:{$product->tenant_id}"])->flush();
// Clear specific cache if featured status changed
if (isset($data['is_featured'])) {
Cache::tags(['products', 'featured'])->flush();
}
return $product->fresh();
}
}
Model-Level Caching
namespace App\Models\Concerns;
use Illuminate\Support\Facades\Cache;
trait Cacheable
{
/**
* Boot the trait
*/
protected static function bootCacheable(): void
{
static::updated(function ($model) {
$model->flushCache();
});
static::deleted(function ($model) {
$model->flushCache();
});
}
/**
* Get cached model
*/
public static function findCached(int $id): ?self
{
$cacheKey = static::getCacheKey($id);
return Cache::tags([static::getCacheTag()])
->remember($cacheKey, 3600, function () use ($id) {
return static::find($id);
});
}
/**
* Flush model cache
*/
public function flushCache(): void
{
Cache::tags([static::getCacheTag()])->flush();
Cache::forget(static::getCacheKey($this->id));
}
/**
* Get cache key for model
*/
protected static function getCacheKey(int $id): string
{
return sprintf('%s:%d', static::class, $id);
}
/**
* Get cache tag for model
*/
protected static function getCacheTag(): string
{
return Str::plural(Str::snake(class_basename(static::class)));
}
}
// Usage in Model
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use App\Models\Concerns\Cacheable;
class Product extends Model
{
use Cacheable;
// Your model code
}
// Controller usage
public function show(int $id)
{
$product = Product::findCached($id);
return view('products.show', compact('product'));
}
Cache Warming Strategy
namespace App\Console\Commands;
use Illuminate\Console\Command;
use App\Services\CacheWarmingService;
class WarmCache extends Command
{
protected $signature = 'cache:warm {--tenant=}';
protected $description = 'Warm up application cache';
public function handle(CacheWarmingService $service): int
{
$this->info('Starting cache warming...');
if ($tenantId = $this->option('tenant')) {
$service->warmTenantCache($tenantId);
$this->info("Cache warmed for tenant: {$tenantId}");
} else {
$service->warmGlobalCache();
$this->info('Global cache warmed');
}
return 0;
}
}
namespace App\Services;
use App\Models\Tenant;
use Illuminate\Support\Facades\Cache;
class CacheWarmingService
{
/**
* Warm global cache
*/
public function warmGlobalCache(): void
{
// Cache configuration data
Cache::tags(['config'])->remember('app:settings', 3600, function () {
return Setting::pluck('value', 'key')->toArray();
});
// Cache popular products
Cache::tags(['products', 'popular'])->remember('products:popular', 3600, function () {
return Product::withCount('orders')
->orderBy('orders_count', 'desc')
->limit(50)
->get();
});
// Cache frequently accessed data
Cache::tags(['categories'])->remember('categories:all', 7200, function () {
return Category::with('children')->whereNull('parent_id')->get();
});
}
/**
* Warm tenant-specific cache
*/
public function warmTenantCache(int $tenantId): void
{
$tenant = Tenant::findOrFail($tenantId);
$tenant->makeCurrent();
// Warm dashboard stats
app(ProductService::class)->getDashboardStats($tenantId);
// Warm frequently accessed products
app(ProductService::class)->getProductsByTenant($tenantId);
// Warm user permissions
$this->warmUserPermissions($tenantId);
}
/**
* Warm user permissions cache
*/
protected function warmUserPermissions(int $tenantId): void
{
User::where('tenant_id', $tenantId)
->chunk(100, function ($users) {
foreach ($users as $user) {
Cache::tags(["user:{$user->id}"])
->remember("user:{$user->id}:permissions", 3600, function () use ($user) {
return $user->getAllPermissions()->pluck('name')->toArray();
});
}
});
}
}
Redis Cluster for High Availability
// config/database.php - Redis Cluster Configuration
'redis' => [
'client' => 'phpredis',
'options' => [
'cluster' => 'redis',
],
'clusters' => [
'default' => [
[
'host' => env('REDIS_CLUSTER_NODE1_HOST', '127.0.0.1'),
'password' => env('REDIS_PASSWORD'),
'port' => env('REDIS_CLUSTER_NODE1_PORT', 6379),
'database' => 0,
],
[
'host' => env('REDIS_CLUSTER_NODE2_HOST', '127.0.0.1'),
'password' => env('REDIS_PASSWORD'),
'port' => env('REDIS_CLUSTER_NODE2_PORT', 6380),
'database' => 0,
],
[
'host' => env('REDIS_CLUSTER_NODE3_HOST', '127.0.0.1'),
'password' => env('REDIS_PASSWORD'),
'port' => env('REDIS_CLUSTER_NODE3_PORT', 6381),
'database' => 0,
],
],
],
],
Queue Optimization for Background Processing
Queues are essential for scaling Laravel applications. They move time-consuming tasks out of the request-response cycle, dramatically improving user experience.
Setting Up Laravel Horizon
Laravel Horizon provides a beautiful dashboard and robust configuration for Redis queues.
composer require laravel/horizon
php artisan horizon:install
php artisan migrate
// config/horizon.php
return [
'use' => 'default',
'prefix' => env('HORIZON_PREFIX', 'horizon:'),
'middleware' => ['web', 'auth:admin'],
'waits' => [
'redis:default' => 60,
'redis:tenant' => 30,
],
'trim' => [
'recent' => 60,
'pending' => 60,
'completed' => 60,
'failed' => 10080,
'monitored' => 10080,
],
'fast_termination' => false,
'memory_limit' => 64,
'defaults' => [
'supervisor-1' => [
'connection' => 'redis',
'queue' => ['default'],
'balance' => 'auto',
'processes' => 10,
'tries' => 3,
'timeout' => 300,
'nice' => 0,
],
],
'environments' => [
'production' => [
'supervisor-high' => [
'connection' => 'redis',
'queue' => ['high', 'default'],
'balance' => 'auto',
'processes' => 20,
'tries' => 3,
'timeout' => 300,
'nice' => 0,
],
'supervisor-low' => [
'connection' => 'redis',
'queue' => ['low'],
'balance' => 'simple',
'processes' => 5,
'tries' => 3,
'timeout' => 600,
'nice' => 10,
],
'supervisor-tenant' => [
'connection' => 'redis',
'queue' => ['tenant'],
'balance' => 'auto',
'processes' => 15,
'tries' => 3,
'timeout' => 300,
'nice' => 0,
],
],
'local' => [
'supervisor-1' => [
'connection' => 'redis',
'queue' => ['default'],
'balance' => 'simple',
'processes' => 3,
'tries' => 3,
'timeout' => 300,
],
],
],
];
Intelligent Job Design
namespace App\Jobs;
use App\Models\Order;
use App\Models\Tenant;
use App\Notifications\OrderConfirmation;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Queue\Middleware\WithoutOverlapping;
use Illuminate\Queue\Middleware\RateLimited;
class ProcessOrder implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
/**
* The number of times the job may be attempted.
*/
public int $tries = 3;
/**
* The number of seconds the job can run before timing out.
*/
public int $timeout = 300;
/**
* The maximum number of unhandled exceptions to allow before failing.
*/
public int $maxExceptions = 3;
/**
* Delete the job if its models no longer exist.
*/
public bool $deleteWhenMissingModels = true;
/**
* Create a new job instance.
*/
public function __construct(
public Order $order,
public ?Tenant $tenant = null
) {
$this->tenant = $this->tenant ?? tenant();
$this->onQueue('high'); // High priority queue
}
/**
* Get the middleware the job should pass through.
*/
public function middleware(): array
{
return [
new WithoutOverlapping($this->order->id),
new RateLimited('orders'),
];
}
/**
* Execute the job.
*/
public function handle(): void
{
// Make tenant current for multi-tenant apps
$this->tenant?->makeCurrent();
// Process payment
$this->processPayment();
// Update inventory
$this->updateInventory();
// Generate invoice
$this->generateInvoice();
// Send confirmation
$this->order->customer->notify(
new OrderConfirmation($this->order)
);
// Update order status
$this->order->update(['status' => 'processing']);
}
/**
* Handle a job failure.
*/
public function failed(\Throwable $exception): void
{
// Log failure
logger()->error('Order processing failed', [
'order_id' => $this->order->id,
'tenant_id' => $this->tenant?->id,
'error' => $exception->getMessage(),
]);
// Update order status
$this->order->update(['status' => 'failed']);
// Notify admin
// Notification::send(Admin::all(), new OrderProcessingFailed($this->order));
}
/**
* Calculate the number of seconds to wait before retrying the job.
*/
public function backoff(): array
{
return [30, 60, 120]; // Exponential backoff
}
/**
* Process payment
*/
protected function processPayment(): void
{
// Payment processing logic
}
/**
* Update inventory
*/
protected function updateInventory(): void
{
foreach ($this->order->items as $item) {
$item->product->decrement('stock', $item->quantity);
}
}
/**
* Generate invoice
*/
protected function generateInvoice(): void
{
// Dispatch another job for invoice generation
GenerateInvoice::dispatch($this->order)
->onQueue('low');
}
}
Batch Processing for Efficiency
namespace App\Jobs;
use App\Models\User;
use Illuminate\Bus\Batchable;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
class SendNewsletterToUser implements ShouldQueue
{
use Batchable, Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public function __construct(
public User $user,
public string $newsletterContent
) {}
public function handle(): void
{
if ($this->batch()->cancelled()) {
return;
}
// Send newsletter to user
Mail::to($this->user)->send(
new NewsletterMail($this->newsletterContent)
);
}
}
// Controller to dispatch batch
namespace App\Http\Controllers\Admin;
use App\Jobs\SendNewsletterToUser;
use App\Models\User;
use Illuminate\Bus\Batch;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Bus;
class NewsletterController extends Controller
{
public function send(Request $request)
{
$content = $request->input('content');
$batch = Bus::batch([])
->then(function (Batch $batch) {
// All jobs completed successfully
logger()->info('Newsletter sent to all users');
})
->catch(function (Batch $batch, \Throwable $e) {
// First batch job failure
logger()->error('Newsletter batch failed', ['error' => $e->getMessage()]);
})
->finally(function (Batch $batch) {
// The batch has finished executing
logger()->info('Newsletter batch finished', [
'total_jobs' => $batch->totalJobs,
'failed_jobs' => $batch->failedJobs,
]);
})
->name('Newsletter Campaign')
->onQueue('low')
->dispatch();
// Add jobs to batch
User::where('subscribed_to_newsletter', true)
->chunk(100, function ($users) use ($batch, $content) {
$jobs = $users->map(fn($user) => new SendNewsletterToUser($user, $content));
$batch->add($jobs);
});
return response()->json([
'message' => 'Newsletter dispatch started',
'batch_id' => $batch->id,
]);
}
public function status(string $batchId)
{
$batch = Bus::findBatch($batchId);
return response()->json([
'id' => $batch->id,
'name' => $batch->name,
'total_jobs' => $batch->totalJobs,
'pending_jobs' => $batch->pendingJobs,
'failed_jobs' => $batch->failedJobs,
'progress' => $batch->progress(),
'finished' => $batch->finished(),
'cancelled' => $batch->cancelled(),
]);
}
}
Queue Priority Management
// Define queue priorities
// config/queue.php
'connections' => [
'redis' => [
'driver' => 'redis',
'connection' => 'default',
'queue' => env('REDIS_QUEUE', 'default'),
'retry_after' => 90,
'block_for' => null,
],
],
// Job dispatching with priorities
namespace App\Services;
class NotificationService
{
public function sendCriticalAlert($data): void
{
SendCriticalAlert::dispatch($data)
->onQueue('critical'); // Highest priority
}
public function sendTransactionalEmail($data): void
{
SendTransactionalEmail::dispatch($data)
->onQueue('high'); // High priority
}
public function sendMarketingEmail($data): void
{
SendMarketingEmail::dispatch($data)
->onQueue('low'); // Low priority
}
public function generateReport($data): void
{
GenerateReport::dispatch($data)
->onQueue('reports') // Dedicated queue
->delay(now()->addMinutes(5)); // Delayed execution
}
}
// Process queues in order of priority
// Start Horizon with priority configuration
Database Performance Tuning
Database queries are often the biggest performance bottleneck in Laravel applications. Optimizing them can provide massive performance gains.
Query Optimization Techniques
namespace App\Repositories;
use App\Models\Order;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Facades\DB;
class OrderRepository
{
/**
* Bad: N+1 Query Problem
*/
public function getOrdersBad(): Collection
{
$orders = Order::all();
foreach ($orders as $order) {
echo $order->customer->name; // N+1 queries
echo $order->items->count(); // N+1 queries
}
return $orders;
}
/**
* Good: Eager Loading
*/
public function getOrdersGood(): Collection
{
return Order::with(['customer', 'items.product'])
->latest()
->get();
}
/**
* Better: Selective Eager Loading with Constraints
*/
public function getOrdersOptimized(): Collection
{
return Order::with([
'customer:id,name,email', // Only load needed columns
'items' => function ($query) {
$query->select('id', 'order_id', 'product_id', 'quantity', 'price')
->with('product:id,name,sku');
},
])
->select(['id', 'customer_id', 'total', 'status', 'created_at'])
->latest()
->paginate(20);
}
/**
* Advanced: Chunking for Large Datasets
*/
public function processLargeOrders(callable $callback): void
{
Order::where('status', 'pending')
->chunkById(500, function ($orders) use ($callback) {
foreach ($orders as $order) {
$callback($order);
}
});
}
/**
* Optimized Aggregations
*/
public function getOrderStatistics(int $tenantId): array
{
return DB::table('orders')
->where('tenant_id', $tenantId)
->selectRaw('
COUNT(*) as total_orders,
SUM(total) as total_revenue,
AVG(total) as average_order_value,
COUNT(CASE WHEN status = "completed" THEN 1 END) as completed_orders,
COUNT(CASE WHEN status = "pending" THEN 1 END) as pending_orders
')
->first();
}
/**
* Efficient Existence Checks
*/
public function hasActiveSubscription(int $userId): bool
{
// Bad: loads entire model
// return Subscription::where('user_id', $userId)->first() !== null;
// Good: uses exists()
return Subscription::where('user_id', $userId)
->where('status', 'active')
->where('expires_at', '>', now())
->exists();
}
}
Database Indexing Strategy
// Migration with proper indexes
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('orders', function (Blueprint $table) {
$table->id();
$table->foreignId('tenant_id')->constrained()->onDelete('cascade');
$table->foreignId('customer_id')->constrained()->onDelete('cascade');
$table->string('order_number')->unique();
$table->decimal('subtotal', 10, 2);
$table->decimal('tax', 10, 2);
$table->decimal('total', 10, 2);
$table->string('status')->default('pending');
$table->timestamp('paid_at')->nullable();
$table->timestamps();
$table->softDeletes();
// Composite indexes for common queries
$table->index(['tenant_id', 'status']);
$table->index(['tenant_id', 'created_at']);
$table->index(['customer_id', 'status']);
$table->index(['status', 'paid_at']);
// Full-text index for search
$table->fullText(['order_number']);
});
}
};
// Check missing indexes
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
class AnalyzeSlowQueries extends Command
{
protected $signature = 'db:analyze-slow-queries';
public function handle(): void
{
// Enable slow query log
DB::statement("SET GLOBAL slow_query_log = 'ON'");
DB::statement("SET GLOBAL long_query_time = 1");
$this->info('Slow query log enabled. Queries taking > 1 second will be logged.');
// Analyze table indexes
$tables = DB::select('SHOW TABLES');
foreach ($tables as $table) {
$tableName = array_values((array)$table)[0];
$indexes = DB::select("SHOW INDEX FROM {$tableName}");
$this->info("Table: {$tableName}");
$this->table(
['Column', 'Index Name', 'Cardinality'],
collect($indexes)->map(fn($idx) => [
$idx->Column_name,
$idx->Key_name,
$idx->Cardinality
])
);
}
}
}
Query Result Caching
namespace App\Services;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
class ReportingService
{
/**
* Cache expensive report queries
*/
public function getMonthlyRevenue(int $tenantId, int $year, int $month): array
{
$cacheKey = "tenant:{$tenantId}:revenue:{$year}:{$month}";
return Cache::tags(['reports', "tenant:{$tenantId}"])
->remember($cacheKey, 3600, function () use ($tenantId, $year, $month) {
return DB::table('orders')
->where('tenant_id', $tenantId)
->whereYear('created_at', $year)
->whereMonth('created_at', $month)
->where('status', 'completed')
->selectRaw('
DATE(created_at) as date,
COUNT(*) as orders_count,
SUM(total) as revenue
')
->groupBy('date')
->orderBy('date')
->get()
->toArray();
});
}
/**
* Invalidate report cache when orders change
*/
public function invalidateRevenueCache(int $tenantId): void
{
Cache::tags(['reports', "tenant:{$tenantId}"])->flush();
}
}
Database Connection Pooling
// config/database.php
'mysql' => [
'driver' => 'mysql',
'host' => env('DB_HOST', '127.0.0.1'),
'port' => env('DB_PORT', '3306'),
'database' => env('DB_DATABASE', 'forge'),
'username' => env('DB_USERNAME', 'forge'),
'password' => env('DB_PASSWORD', ''),
'charset' => 'utf8mb4',
'collation' => 'utf8mb4_unicode_ci',
'prefix' => '',
'strict' => true,
'engine' => null,
'options' => extension_loaded('pdo_mysql') ? array_filter([
PDO::MYSQL_ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'),
PDO::ATTR_PERSISTENT => true, // Enable persistent connections
PDO::ATTR_EMULATE_PREPARES => false,
PDO::MYSQL_ATTR_USE_BUFFERED_QUERY => true,
]) : [],
'pool' => [
'min_connections' => 5,
'max_connections' => 20,
],
],
// Read/Write connection splitting
'mysql' => [
'read' => [
'host' => [
env('DB_READ_HOST_1', '127.0.0.1'),
env('DB_READ_HOST_2', '127.0.0.1'),
],
],
'write' => [
'host' => [
env('DB_WRITE_HOST', '127.0.0.1'),
],
],
'sticky' => true, // Sticky reads for consistency
// ... other options
],
CDN Integration and Asset Optimization
Content Delivery Networks (CDNs) dramatically improve page load times by serving static assets from servers geographically close to users.
Amazon CloudFront Integration
// 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' => false,
'visibility' => 'public',
],
'cloudfront' => [
'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_CLOUDFRONT_URL'), // CloudFront distribution URL
'visibility' => 'public',
],
],
// .env
AWS_CLOUDFRONT_URL=https://d111111abcdef8.cloudfront.net
namespace App\Services;
use Illuminate\Support\Facades\Storage;
class AssetService
{
/**
* Upload file to S3 with CloudFront distribution
*/
public function uploadFile($file, string $path): string
{
$filename = time() . '_' . $file->getClientOriginalName();
$filePath = $path . '/' . $filename;
// Upload to S3
Storage::disk('s3')->put($filePath, file_get_contents($file), 'public');
// Return CloudFront URL
return Storage::disk('cloudfront')->url($filePath);
}
/**
* Generate signed CloudFront URL for private content
*/
public function getSignedUrl(string $path, int $expiresInMinutes = 60): string
{
$cloudFront = new \Aws\CloudFront\CloudFrontClient([
'version' => 'latest',
'region' => config('filesystems.disks.s3.region'),
]);
$resourceKey = config('filesystems.disks.cloudfront.url') . '/' . $path;
$expires = time() + ($expiresInMinutes * 60);
return $cloudFront->getSignedUrl([
'url' => $resourceKey,
'expires' => $expires,
'private_key' => storage_path('cloudfront-private-key.pem'),
'key_pair_id' => env('AWS_CLOUDFRONT_KEY_PAIR_ID'),
]);
}
}
Asset Versioning and Cache Busting
// webpack.mix.js or vite.config.js
mix.js('resources/js/app.js', 'public/js')
.postCss('resources/css/app.css', 'public/css', [
require('tailwindcss'),
])
.version(); // Add version hash
// Blade template
<link rel="stylesheet" href="{{ mix('css/app.css') }}">
<script src="{{ mix('js/app.js') }}"></script>
// This generates:
// <link rel="stylesheet" href="/css/app.css?id=abc123">
// <script src="/js/app.js?id=def456"></script>
Image Optimization
namespace App\Services;
use Intervention\Image\Facades\Image;
use Illuminate\Support\Facades\Storage;
class ImageOptimizationService
{
/**
* Upload and optimize image
*/
public function uploadAndOptimize($file, string $path): array
{
$filename = time() . '_' . $file->getClientOriginalName();
// Load image
$image = Image::make($file);
// Generate multiple sizes
$sizes = [
'thumbnail' => [150, 150],
'medium' => [400, 400],
'large' => [800, 800],
];
$urls = [];
foreach ($sizes as $size => [$width, $height]) {
$resized = clone $image;
$resized->fit($width, $height, function ($constraint) {
$constraint->upsize();
});
// Optimize quality
$resized->encode('jpg', 85);
$sizePath = "{$path}/{$size}_{$filename}";
Storage::disk('cloudfront')->put($sizePath, $resized->stream());
$urls[$size] = Storage::disk('cloudfront')->url($sizePath);
}
return $urls;
}
/**
* Convert images to WebP format
*/
public function convertToWebP($file, string $path): string
{
$image = Image::make($file);
$filename = time() . '.webp';
$webp = $image->encode('webp', 90);
$webpPath = "{$path}/{$filename}";
Storage::disk('cloudfront')->put($webpPath, $webp);
return Storage::disk('cloudfront')->url($webpPath);
}
}
Horizontal Scaling with Load Balancers
Horizontal scaling involves adding more application servers behind a load balancer to distribute traffic.
Load Balancer Configuration (AWS ALB Example)
# docker-compose.yml for local testing
version: '3.8'
services:
nginx-lb:
image: nginx:latest
ports:
- "80:80"
volumes:
- ./nginx-lb.conf:/etc/nginx/nginx.conf
depends_on:
- app1
- app2
- app3
app1:
build: .
environment:
- APP_ENV=production
- REDIS_HOST=redis
- DB_HOST=mysql
depends_on:
- mysql
- redis
app2:
build: .
environment:
- APP_ENV=production
- REDIS_HOST=redis
- DB_HOST=mysql
depends_on:
- mysql
- redis
app3:
build: .
environment:
- APP_ENV=production
- REDIS_HOST=redis
- DB_HOST=mysql
depends_on:
- mysql
- redis
mysql:
image: mysql:8.0
environment:
MYSQL_ROOT_PASSWORD: secret
MYSQL_DATABASE: laravel
redis:
image: redis:alpine
# nginx-lb.conf
upstream laravel_backend {
least_conn; # Load balancing method
server app1:9000 weight=1;
server app2:9000 weight=1;
server app3:9000 weight=1;
}
server {
listen 80;
server_name yourapp.com;
location / {
proxy_pass http://laravel_backend;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Health check
proxy_next_upstream error timeout invalid_header http_500 http_502 http_503;
proxy_connect_timeout 5s;
proxy_send_timeout 10s;
proxy_read_timeout 10s;
}
}
Session Management for Multiple Servers
// config/session.php
'driver' => env('SESSION_DRIVER', 'redis'),
'connection' => env('SESSION_CONNECTION', 'session'),
'store' => env('SESSION_STORE', 'session'),
// config/database.php
'redis' => [
'session' => [
'url' => env('REDIS_URL'),
'host' => env('REDIS_HOST', '127.0.0.1'),
'password' => env('REDIS_PASSWORD'),
'port' => env('REDIS_PORT', '6379'),
'database' => env('REDIS_SESSION_DB', '2'),
],
],
Health Check Endpoint
// routes/web.php
Route::get('/health', function () {
// Check database connection
try {
DB::connection()->getPdo();
$dbStatus = 'healthy';
} catch (\Exception $e) {
$dbStatus = 'unhealthy';
}
// Check Redis connection
try {
Cache::store('redis')->get('health-check');
$redisStatus = 'healthy';
} catch (\Exception $e) {
$redisStatus = 'unhealthy';
}
// Check queue connection
try {
Queue::connection()->size();
$queueStatus = 'healthy';
} catch (\Exception $e) {
$queueStatus = 'unhealthy';
}
$overallStatus = ($dbStatus === 'healthy' && $redisStatus === 'healthy' && $queueStatus === 'healthy')
? 'healthy'
: 'unhealthy';
$statusCode = $overallStatus === 'healthy' ? 200 : 503;
return response()->json([
'status' => $overallStatus,
'timestamp' => now()->toIso8601String(),
'services' => [
'database' => $dbStatus,
'redis' => $redisStatus,
'queue' => $queueStatus,
],
], $statusCode);
})->name('health-check');
Monitoring and Performance Metrics
You can't optimize what you don't measure. Comprehensive monitoring is essential for scaling Laravel applications.
Laravel Telescope for Development
composer require laravel/telescope --dev
php artisan telescope:install
php artisan migrate
// config/telescope.php
'enabled' => env('TELESCOPE_ENABLED', false),
'middleware' => [
'web',
'auth:admin', // Protect in production
],
'watchers' => [
Watchers\CacheWatcher::class => env('TELESCOPE_CACHE_WATCHER', true),
Watchers\QueryWatcher::class => [
'enabled' => env('TELESCOPE_QUERY_WATCHER', true),
'slow' => 100, // Log queries slower than 100ms
],
Watchers\RequestWatcher::class => [
'enabled' => env('TELESCOPE_REQUEST_WATCHER', true),
'size_limit' => env('TELESCOPE_RESPONSE_SIZE_LIMIT', 64),
],
],
New Relic Integration
composer require philkra/elastic-apm-laravel
// config/elastic-apm.php
return [
'active' => env('ELASTIC_APM_ACTIVE', false),
'app' => [
'appName' => env('ELASTIC_APM_APP_NAME', 'Laravel'),
'appVersion' => env('APP_VERSION', '1.0.0'),
],
'server' => [
'serverUrl' => env('ELASTIC_APM_SERVER_URL', 'http://127.0.0.1:8200'),
'secretToken' => env('ELASTIC_APM_SECRET_TOKEN', null),
],
'transactions' => [
'threshold' => env('ELASTIC_APM_TRANSACTION_THRESHOLD', 200),
],
];
// Custom transaction tracking
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
class TrackPerformanceMiddleware
{
public function handle(Request $request, Closure $next)
{
$transaction = app('elastic-apm')->startTransaction(
$request->path(),
'request'
);
$response = $next($request);
$transaction->end();
return $response;
}
}
Custom Performance Metrics
namespace App\Services;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
class PerformanceMetricsService
{
/**
* Track API response times
*/
public function trackResponseTime(string $endpoint, float $duration): void
{
$key = "metrics:response_time:{$endpoint}:" . now()->format('Y-m-d-H');
Cache::increment($key . ':count');
Cache::increment($key . ':total', (int)($duration * 1000)); // Convert to ms
// Expire after 7 days
Cache::put($key . ':expires', true, now()->addDays(7));
}
/**
* Get average response time for endpoint
*/
public function getAverageResponseTime(string $endpoint, string $date = null): float
{
$date = $date ?? now()->format('Y-m-d-H');
$key = "metrics:response_time:{$endpoint}:{$date}";
$count = Cache::get($key . ':count', 0);
$total = Cache::get($key . ':total', 0);
return $count > 0 ? ($total / $count) : 0;
}
/**
* Track database query performance
*/
public function trackQueryPerformance(): void
{
DB::listen(function ($query) {
if ($query->time > 1000) { // Slower than 1 second
logger()->warning('Slow query detected', [
'sql' => $query->sql,
'bindings' => $query->bindings,
'time' => $query->time,
]);
}
// Store metrics
$key = 'metrics:queries:' . now()->format('Y-m-d');
Cache::increment($key . ':total');
Cache::increment($key . ':slow', $query->time > 100 ? 1 : 0);
});
}
/**
* Get system health metrics
*/
public function getSystemMetrics(): array
{
return [
'memory_usage' => memory_get_usage(true) / 1024 / 1024, // MB
'memory_peak' => memory_get_peak_usage(true) / 1024 / 1024, // MB
'cpu_load' => sys_getloadavg(),
'disk_free' => disk_free_space('/') / 1024 / 1024 / 1024, // GB
'disk_total' => disk_total_space('/') / 1024 / 1024 / 1024, // GB
'database_connections' => DB::connection()->select('SHOW STATUS LIKE "Threads_connected"')[0]->Value ?? 0,
'redis_memory' => Cache::store('redis')->getRedis()->info('memory')['used_memory_human'] ?? 'N/A',
'queue_size' => Queue::size('default'),
];
}
}
Real-World Case Study: Scaling a Multi-Tenant SaaS
At NeedLaravelSite, we helped a project management SaaS scale from 100 to 10,000+ users with the following optimizations:
Initial State:
- Single server deployment
- File-based sessions and cache
- No queue workers
- Average response time: 2.5 seconds
- Database queries: 150+ per page
Optimizations Implemented:
Phase 1: Quick Wins (Week 1-2)
- Enabled Redis caching for queries and sessions
- Added database indexes on frequently queried columns
- Implemented eager loading to eliminate N+1 queries
- Set up Laravel Horizon for queue management
- Result: Response time reduced to 800ms, 60% fewer database queries
Phase 2: Infrastructure Scaling (Week 3-4)
- Migrated to multi-server setup with load balancer
- Configured Redis cluster for high availability
- Integrated Amazon CloudFront CDN
- Set up database read replicas
- Result: Response time reduced to 400ms, handled 10x traffic
Phase 3: Advanced Optimization (Week 5-6)
- Implemented intelligent cache warming
- Added batch processing for background jobs
- Optimized database queries with raw SQL for complex aggregations
- Set up comprehensive monitoring with New Relic
- Result: Response time stabilized at 250ms, 99.9% uptime
Final Results:
- 10,000+ concurrent users supported
- 250ms average response time (90% improvement)
- 99.9% uptime over 6 months
- 60% reduction in infrastructure costs through optimization
- Zero downtime deployments with rolling updates
Key Learnings:
- Cache aggressively, invalidate intelligently
- Monitor everything from day one
- Database optimization provides the biggest wins
- Horizontal scaling requires architectural changes early
- Background jobs are essential for good UX
Conclusion
Scaling Laravel SaaS applications is a journey, not a destination. As your user base grows, you'll continuously need to identify bottlenecks and optimize. The key is to build scalability into your architecture from the start.
Key Takeaways:
Redis caching is non-negotiable for production SaaS applications. Cache aggressively, use cache tags for organized invalidation, and implement cache warming for critical data.
Queue everything that doesn't need to happen immediately. Emails, reports, notifications, data processing — move them all to background jobs with Laravel Horizon.
Optimize database queries relentlessly. Use eager loading, add proper indexes, leverage database-level aggregations, and consider read replicas for heavy read operations.
Plan for horizontal scaling early. Use shared storage (S3), distributed caching (Redis), and centralized sessions. This makes adding servers seamless when you need to scale.
Monitor comprehensively from day one. You can't optimize what you don't measure. Track response times, query performance, queue depths, and system resources.
CDN integration is essential for global SaaS applications. Serve static assets from geographically distributed servers to improve load times worldwide.
Need Expert Help Scaling Your Laravel SaaS?
At NeedLaravelSite, we specialize in scaling Laravel applications for high-traffic production environments. With 8+ years of Laravel expertise and 100+ successful projects, we can help you:
- Diagnose performance bottlenecks and implement optimizations
- Architect scalable multi-server infrastructure
- Optimize database queries and implement efficient caching strategies
- Set up comprehensive monitoring and alerting systems
- Migrate to cloud-native architecture (AWS, DigitalOcean, Google Cloud)
Get in touch today and let's discuss how we can help your Laravel SaaS scale efficiently and cost-effectively.