Laravel Performance Optimization: Production Best Practices
Laravel Performance Optimization: From Slow to Blazing Fast
Reading Time: 35 minutes
Introduction
A slow Laravel application frustrates users, hurts SEO rankings, and increases server costs. Most performance issues in Laravel are caused by the same patterns: N+1 queries, missing caches, blocking queues, and unoptimized assets.
This guide covers every layer of optimization — from framework configuration to database indexing, from queue workers to HTTP caching. Apply these patterns and your Laravel app will handle 10x the traffic with the same hardware.
The Performance Pyramid
graph TD
A[Performance Pyramid] --> B[Database]
A --> C[Caching]
A --> D[Application Code]
A --> E[Queue & Jobs]
A --> F[Asset Optimization]
A --> G[Infrastructure]
B --> B1[Indexes]
B --> B2[Eager Loading]
B --> B3[Chunking]
B --> B4[Query Optimization]
C --> C1[Config Cache]
C --> C2[Route Cache]
C --> C3[View Cache]
C --> C4[Data Cache]
D --> D1[Opcache]
D --> D2[Lazy Collections]
D --> D3[Deferred Providers]
D --> D4[Octane]
E --> E1[Queue Workers]
E --> E2[Higher-Order Messages]
E --> E3[Queue Monitoring]
F --> F1[Vite/Vite bundling]
F --> F2[Image Optimization]
F --> F3[Font Subsetting]
G --> G1[PHP-FPM Tuning]
G --> G2[Database Connection Pooling]
G --> G3[CDN]
Caching Strategy
Config Cache
Combines all config files into a single cached file:
php artisan config:cache
Before (without cache): 50+ file reads, multiple array merges After (with cache): 1 file read, zero array merges
Always run during deployment:
php artisan config:cache && php artisan route:cache && php artisan view:cache
Route Cache
php artisan route:cache
Works only with routes using controllers or closures that don't have closures depending on runtime data.
View Cache
php artisan view:cache
Pre-compiles all Blade templates — the first request doesn't pay compilation cost.
Event Cache
php artisan event:cache
Caches the event-to-listener mapping, avoiding disk scans on every request.
Data Caching
// ❌ Database query on every request
$categories = Category::all();
// ✅ Cache with TTL
$categories = Cache::remember('categories.all', 3600, function () {
return Category::all();
});
// Cache with tags (group invalidation)
Cache::tags(['products', 'prices'])->remember('products.featured', 3600, function () {
return Product::with('prices')->where('featured', true)->get();
});
// Clear by tag
Cache::tags(['prices'])->flush();
Cache drivers ranked by performance:
| Driver | Speed | Persistence | Best For |
|---|---|---|---|
| Redis | ⚡⚡⚡⚡⚡ | Yes | Production, sessions, cache, queues |
| Memcached | ⚡⚡⚡⚡⚡ | Yes | Large caches, simple key-value |
| Database | ⚡⚡ | Yes | Development, shared hosting |
| File | ⚡ | Yes | Development, single-server |
| Array | ⚡⚡⚡⚡⚡ | No (per request) | Tests |
Database Optimization
Indexing Strategy
// Migration with proper indexes
Schema::table('orders', function (Blueprint $table) {
$table->index('status'); // Simple index for filtering
$table->index('created_at'); // Index for sorting
$table->index(['user_id', 'status']); // Composite index for common query
});
Identify missing indexes:
-- MySQL: Find slow queries
SELECT * FROM `orders` WHERE `status` = 'pending' ORDER BY `created_at` DESC;
-- ✅ Should have: INDEX(`status`, `created_at`)
PostgreSQL: Use EXPLAIN ANALYZE
EXPLAIN ANALYZE SELECT * FROM orders WHERE status = 'pending';
-- Look for "Seq Scan" on large tables — it signals a missing index
Chunking Large Datasets
// ❌ Loads all 1M records into memory
$users = User::all();
foreach ($users as $user) { /* ... */ }
// ✅ Chunking — 100 records at a time
User::chunk(100, function ($users) {
foreach ($users as $user) {
// Process 100 users
}
});
// ✅ Chunk by ID for consistent chunking (handles deletes gracefully)
User::chunkById(100, function ($users) {
foreach ($users as $user) {
// ...
}
});
// ✅ Lazy collections — memory efficient iteration
foreach (User::lazy(100) as $user) {
// Keeps memory constant regardless of dataset size
}
Cursor vs Chunk
| Method | Memory | Use Case |
|---|---|---|
cursor() | ~1KB | Simple read-only iteration |
lazy() | ~1KB + model overhead | When you need models |
chunk() | Scales with chunk size | When you need to update records |
chunkById() | Scales with chunk size | When records may be deleted during iteration |
Eager Loading
// ❌ N+1: 1 (posts) + N (authors) queries
$posts = Post::all();
foreach ($posts as $post) {
echo $post->author->name;
}
// ✅ Eager: 2 queries total
$posts = Post::with('author')->get();
Raw SQL for Complex Aggregations
// ❌ Collection-level filtering after load
$users = User::all()->groupBy('country_id')->map->count();
// ✅ Database-level aggregation
$userCounts = DB::table('users')
->select('country_id', DB::raw('count(*) as total'))
->groupBy('country_id')
->pluck('total', 'country_id');
OPcache Configuration
PHP OPcache is mandatory for production — it caches compiled PHP bytecode.
; php.ini recommended settings
opcache.enable=1
opcache.memory_consumption=256
opcache.interned_strings_buffer=16
opcache.max_accelerated_files=20000
opcache.revalidate_freq=60
opcache.fast_shutdown=1
; For development
opcache.revalidate_freq=0
opcache.validate_timestamps=1
Verify OPcache is working:
php -r "print_r(opcache_get_status()['opcache_statistics']);"
Queues: Moving Work Off the Request
// ❌ Slow response — waits for email to send
class OrderController extends Controller
{
public function store(Request $request): JsonResponse
{
$order = Order::create($request->validated());
Mail::to($order->user)->send(new OrderConfirmation($order)); // 500ms+
return response()->json($order);
}
}
// ✅ Fast response — dispatch to queue
class StoreOrderAction
{
public function execute(array $data): Order
{
$order = Order::create($data);
SendOrderConfirmation::dispatch($order)->onQueue('emails');
return $order;
}
}
Queue Configuration Best Practices
# .env — Use Redis for production
QUEUE_CONNECTION=redis
# .env — Never use 'sync' in production
Queue Worker Tuning
# One worker per CPU core
php artisan queue:work redis --queue=high,default --tries=3 --timeout=30
# For heavy workloads, use supervisor to manage multiple workers
; /etc/supervisor/conf.d/laravel-worker.conf
[program:laravel-worker]
process_name=%(program_name)s_%(process_num)02d
command=php /var/www/artisan queue:work redis --sleep=3 --tries=3
numprocs=8
autostart=true
autorestart=true
Job Batching
Process large datasets in parallel:
$batch = Bus::batch([
new ProcessUserData($user1),
new ProcessUserData($user2),
// ...
])->dispatch();
Job Middleware for Rate Limiting
class ProcessWebhook implements ShouldQueue
{
public function middleware(): array
{
return [new RateLimited];
}
}
Laravel Octane
Octane supercharges Laravel by keeping the application in memory between requests.
composer require laravel/octane
php artisan octane:install
php artisan octane:start --server=swoole
Performance gain: 10-50x requests per second.
Important considerations:
| Aspect | Traditional | Octane |
|---|---|---|
| Memory | Fresh per request | Shared |
| Boot time | Full bootstrap every request | Once |
| Static state | Reset naturally | Must be manually reset |
| Singleton behavior | Per-request singleton | Global singleton |
Octane-safe code patterns:
// ❌ Static property persists across requests
class Service
{
public static array $cache = [];
}
// ✅ Use Laravel's cache instead
class Service
{
public function get(string $key): mixed
{
return Cache::driver('octane')->get($key);
}
}
Profiling & Monitoring
Laravel Debugbar (Development)
composer require barryvdh/laravel-debugbar --dev
Shows: queries, memory, time, views, route, session data.
Telescope (Development & Staging)
composer require laravel/telescope --dev
php artisan telescope:install
Records: requests, queries, exceptions, logs, mail, notifications, cache hits/misses.
Blackfire.io (Production)
Professional profiling tool that shows flame graphs of request execution.
Application Monitoring
| Tool | Type | Cost |
|---|---|---|
| Laravel Pulse | Self-hosted, free | Free |
| Laravel Horizon | Queue monitoring | Free |
| Sentry | Error tracking | Freemium |
| New Relic | APM | Paid |
| Scout APM | APM | Paid |
Common Mistakes
Mistake 1: Disabling OPcache in Production
; ❌ OPcache disabled
opcache.enable=0
Impact: Each request recompiles all PHP files — 50-100ms overhead per request.
Mistake 2: Forgetting Cache Tags Flush on Update
// ❌ Cache becomes stale
class ProductController extends Controller
{
public function update(Request $request, Product $product): JsonResponse
{
$product->update($request->validated());
return response()->json($product);
// Cache is now stale!
}
}
// ✅ Invalidate related cache
public function update(Request $request, Product $product): JsonResponse
{
$product->update($request->validated());
Cache::tags(['products', 'prices'])->flush();
return response()->json($product);
}
Mistake 3: Using sync Queue Driver in Production
QUEUE_CONNECTION=sync
Impact: Mail sending, notification dispatching, and image processing block the request.
Fix: Use Redis, database, or SQS in production.
Mistake 4: Not Monitoring Queue Workers
# ❌ Workers crash silently
php artisan queue:work redis
# ✅ With failure notification
php artisan queue:work redis --tries=3 --backoff=5
Mistake 5: Loading Everything on Dashboard
// ❌ Dashboard loads every model
$users = User::all(); // 10,000 users
$orders = Order::all(); // 50,000 orders
$products = Product::all(); // 5,000 products
Fix: Use pagination, caching, or lazy loading for dashboard widgets.
Deployment Checklist
# Pre-deployment optimization
php artisan config:cache
php artisan route:cache
php artisan view:cache
php artisan event:cache
# Clear old cached views
php artisan view:clear
# Restart queue workers
php artisan queue:restart
# Restart Octane if used
php artisan octane:reload
# Verify OPcache is enabled
php -r "echo opcache_get_status() ? 'OPcache enabled' : 'OPcache disabled';"
Benchmark: Optimized vs Unoptimized
| Scenario | Unoptimized | Optimized | Improvement |
|---|---|---|---|
| Homepage load | 450ms | 45ms | 10x |
| API list (1000 records) | 1200ms | 35ms | 34x |
| Email order confirmation | 600ms | 5ms | 120x |
| Report generation (50k rows) | 30s | 2s | 15x |
| Concurrent users (same hardware) | 100 | 2000 | 20x |
Interview Questions
- What is OPcache and how do you configure it for Laravel?
- Explain the different cache drivers and when to use each.
- How does Laravel Octane work and what are its trade-offs?
- What queue configuration would you use for a high-traffic application?
- How do you identify and fix N+1 queries in production?
- What is the difference between
cursor()andchunk()? - How would you benchmark a Laravel application?