Practical Laravel Performance Optimization
Performance is the lifeblood of SaaS—users will leave if they have to wait 3 seconds, and slow queries can burn through hundreds of USD in database fees in a single day.
1. What You'll Learn
- Eloquent Query Optimization: Eager Loading / Chunk / Cursor / Lazy
- Caching Strategy: Route/Config/View caching + Redis data caching
- Database Optimization: Indexing Strategies / EXPLAIN Analysis / Read-Write Separation
- OPcache and PHP-FPM Tuning
- Load Testing: k6 / Apache Bench Load Testing and Bottleneck Analysis
2. The True Story of a Fast-Growing SaaS Company
(1) Pain Point: ShopMetrics response time skyrocketed from 200 ms to 5 s
ShopMetrics' user base grew from 1,000 to 50,000, and order data increased from 100,000 to 5 million. Alice noticed that the dashboard took 5 seconds to load, and Bob saw that the monthly database fee had jumped from 50 USD to 500 USD—all due to slow queries burning through the budget. Charlie's analysis revealed that N+1 queries, missing indexes, and lack of caching were the three main culprits.
(2) Systematic Optimization Methods
Through four layers of optimization—queries, caching, indexes, and OPcache—each layer cut the response time in half, ultimately reducing it from 5 seconds to 200 milliseconds.
Before: 5000ms (N+1 queries, no cache, no index)
Step 1: 2500ms (eager loading, fix N+1)
Step 2: 500ms (add indexes + query optimization)
Step 3: 200ms (Redis cache + OPcache)
Step 4: 100ms (CDN + read replica)
(3) Revenue
After Bob's optimization, database costs dropped from 500 USD/month to 150 USD/month, and user retention rates rose from 70% to 90%.
3. Optimizing Eloquent Queries
(1) The N+1 Query Problem
// ❌ N+1: 1 query for tenants + N queries for shops
$tenants = Tenant::all();
foreach ($tenants as $tenant) {
echo $tenant->shops->count(); // 1 extra query per tenant!
}
// Total: 1 + N queries
// ✅ Eager loading: 2 queries total
$tenants = Tenant::with('shops')->get();
foreach ($tenants as $tenant) {
echo $tenant->shops->count(); // No extra query
}
// Total: 2 queries
(2) Comparison of Query Optimization Methods
| Method | Use Case | Memory Usage | Description |
|---|---|---|---|
all() |
Small amount of data | High | Load all into memory at once |
chunk() |
Big Data Iteration | Low | Process in blocks of N records each |
chunkById() |
Big Data Iteration (More Stable) | Low | Chunked by ID, unaffected by data changes |
cursor() |
Stream-based reading | Very low | Generator-based line-by-line reading |
lazy() |
Big Data, but requires aggregate operations | Medium | Load chunks on demand |
each() |
Iteration + Processing | Low | chunk + callback |
(1) ▶ Example: ShopMetrics Order Analysis N+1 Fix
// ❌ Before: Dashboard loads in 5 seconds
// Controller: 1 + 3*N queries per tenant
public function dashboard(Tenant $tenant)
{
$orders = $tenant->orders()
->whereMonth('created_at', now()->month)
->get(); // Query 1
$total = $orders->sum('total');
$byShop = $orders->groupBy('shop_id'); // Lazy loads shops: N queries
$topProducts = $orders->flatMap->items; // Lazy loads items: N queries
$customers = $orders->pluck('customer'); // Lazy loads customer: N queries
}
// ✅ After: Dashboard loads in 500ms
// Controller: 4 queries total
public function dashboard(Tenant $tenant)
{
$orders = $tenant->orders()
->with(['shop:id,name', 'items.product:id,name,price', 'customer:id,name'])
->whereMonth('created_at', now()->month)
->select(['id', 'shop_id', 'customer_id', 'total', 'created_at'])
->get();
$total = $orders->sum('total');
$byShop = $orders->groupBy('shop.name');
$topProducts = $orders->flatMap->items->take(10);
$customers = $orders->pluck('customer.name');
}
Output:
// Execution Successful
(2) ▶ Example: Using chunks and cursors with large datasets
// Process 5 million orders without memory overflow
// Option 1: chunk (processes 500 at a time)
Order::where('tenant_id', $tenant->id)
->chunk(500, function ($orders) {
foreach ($orders as $order) {
$this->calculateOrderMetrics($order);
}
});
// Option 2: chunkById (more stable with concurrent writes)
Order::where('tenant_id', $tenant->id)
->select(['id', 'total', 'status'])
->chunkById(500, function ($orders) {
$metrics = $orders->groupBy('status')->map->sum('total');
Cache::put("metrics:{$tenant->id}", $metrics, 3600);
});
// Option 3: cursor (streaming, lowest memory)
$total = 0;
foreach (Order::where('tenant_id', $tenant->id)->cursor() as $order) {
$total += $order->total;
}
// Option 4: lazy (collection methods available)
$highValue = Order::where('tenant_id', $tenant->id)
->lazy(500)
->filter(fn ($o) => $o->total > 1000)
->count();
Output:
// Execution Successful
4. Caching Strategy
(1) Laravel Caching Hierarchy
flowchart TD
A[Request] --> B{Route Cache?}
B -->|Hit| C[Cached Routes]
B -->|Miss| D[Parse Routes]
D --> E{Config Cache?}
C --> E
E -->|Hit| F[Cached Config]
E -->|Miss| G[Load .env + Config Files]
G --> H{Data Cache?}
F --> H
H -->|Hit| I[Return Cached Data]
H -->|Miss| J[Query DB + Cache Result]
I --> K[Response]
J --> K
(2) Cache Types and Use Cases
| Cache Type | Command/Method | Use Case | Persistence |
|---|---|---|---|
| Config Cache | config:cache |
Production Environment Configuration | Long-term (until cleared) |
| Route Cache | route:cache |
Many routes, few closures | Long-term (until cleared) |
| View Cache | view:cache |
Blade View Compilation | Long-term (until cleared) |
| Data Cache | Cache::put() |
Query Results/Report Data | TTL Expired |
| Query Cache | remember() |
Frequent Queries | TTL Expired |
| OPcache | php.ini | PHP bytecode | Valid until restart |
(1) ▶ Example: ShopMetrics Redis Caching Strategy
// app/Services/DashboardService.php
class DashboardService
{
public function getOverview(Tenant $tenant): array
{
return Cache::remember(
"dashboard:{$tenant->id}:overview",
now()->addMinutes(15),
fn () => $this->calculateOverview($tenant)
);
}
public function getTopProducts(Tenant $tenant, string $period = '7d'): Collection
{
return Cache::remember(
"dashboard:{$tenant->id}:top-products:{$period}",
now()->addHours(1),
fn () => $this->calculateTopProducts($tenant, $period)
);
}
public function getRevenueChart(Tenant $tenant, string $granularity = 'daily'): array
{
return Cache::remember(
"dashboard:{$tenant->id}:revenue:{$granularity}",
now()->addMinutes(30),
fn () => $this->calculateRevenueChart($tenant, $granularity)
);
}
public function invalidateTenantCache(Tenant $tenant): void
{
$prefix = "dashboard:{$tenant->id}";
// Redis KEYS is slow on large datasets; use tags or prefix scan
Cache::getStore()->getConnection()->del(
...Cache::getStore()->getConnection()->keys("{$prefix}:*")
);
}
}
// Controller
class DashboardController extends Controller
{
public function __construct(
private DashboardService $dashboard
) {}
public function index(Tenant $tenant)
{
return response()->json([
'overview' => $this->dashboard->getOverview($tenant),
'top_products' => $this->dashboard->getTopProducts($tenant),
'revenue_chart' => $this->dashboard->getRevenueChart($tenant),
]);
}
}
Output:
// Execution Successful
(3) Cache Tags and Batch Expiration
// Cache with tags (Redis/database drivers only)
Cache::tags(['dashboard', "tenant:{$tenant->id}"])->put(
'overview',
$data,
3600
);
// Invalidate all dashboard cache for a tenant
Cache::tags(["tenant:{$tenant->id}"])->flush();
// Or use cache key conventions with prefix scan
Cache::forget("dashboard:{$tenant->id}:overview");
Cache::forget("dashboard:{$tenant->id}:top-products:7d");
Cache::forget("dashboard:{$tenant->id}:revenue:daily");
5. Database Optimization
(1) Indexing Strategy
// Migration: Add indexes for ShopMetrics queries
Schema::table('orders', function (Blueprint $table) {
// Single column index
$table->index('tenant_id', 'idx_orders_tenant');
$table->index('created_at', 'idx_orders_date');
$table->index('status', 'idx_orders_status');
// Composite index (most useful for multi-condition queries)
$table->index(['tenant_id', 'created_at'], 'idx_orders_tenant_date');
$table->index(['tenant_id', 'status', 'created_at'], 'idx_orders_tenant_status_date');
// Unique index
$table->unique(['tenant_id', 'external_id'], 'uniq_orders_tenant_external');
});
(2) Comparison of Index Types
| Index Type | Syntax | Use Cases | Description |
|---|---|---|---|
| Index | $table->index(col) |
WHERE/ORDER BY | Standard B-tree |
| Unique | $table->unique(col) |
Unique Constraint | B-tree + Duplicate Removal |
| Composite | $table->index([c1,c2]) |
Multi-condition query | Follows the leftmost prefix |
| FullText | $table->fullText(col) |
Text Search | MySQL 8.0+ |
| Spatial | $table->spatialIndex(col) |
Geographic Query | POINT/LINESTRING |
(3) EXPLAIN: Analyzing Queries
-- Analyze slow query
EXPLAIN SELECT * FROM orders
WHERE tenant_id = 1 AND status = 'completed'
ORDER BY created_at DESC LIMIT 20;
-- Look for:
-- type: ALL (full scan) → needs index
-- key: NULL (no index used) → needs index
-- rows: large number → needs optimization
-- Extra: Using filesort → needs composite index
(1) ▶ Example: ShopMetrics Slow Query Diagnosis and Optimization
-- ❌ Before: Full table scan on 5M rows (3000ms)
EXPLAIN SELECT * FROM orders WHERE tenant_id = 42;
-- type: ALL, rows: 5,000,000, key: NULL
-- Add index
ALTER TABLE orders ADD INDEX idx_orders_tenant (tenant_id);
-- ✅ After: Index scan (5ms)
EXPLAIN SELECT * FROM orders WHERE tenant_id = 42;
-- type: ref, rows: 50,000, key: idx_orders_tenant
-- ❌ Still slow: Sorting without index (500ms)
EXPLAIN SELECT * FROM orders
WHERE tenant_id = 42 ORDER BY created_at DESC LIMIT 20;
-- Extra: Using filesort
-- ✅ Composite index (3ms)
ALTER TABLE orders ADD INDEX idx_orders_tenant_date (tenant_id, created_at DESC);
Output:
CREATE TABLE
(4) Read-Write Separation
// config/database.php
'mysql' => [
'read' => [
'host' => env('DB_READ_HOST', 'read-replica.shopmetrics.internal'),
],
'write' => [
'host' => env('DB_WRITE_HOST', 'primary.shopmetrics.internal'),
],
'sticky' => true, // Read from write host after write in same request
'driver' => 'mysql',
'database' => env('DB_DATABASE', 'shopmetrics'),
// ...
],
6. Optimizing OPcache and PHP-FPM
(1) OPcache Configuration
; php.ini or php.d/opcache.ini
opcache.enable=1
opcache.memory_consumption=256 ; 256MB for bytecode cache
opcache.interned_strings_buffer=32 ; 32MB for strings
opcache.max_accelerated_files=40000 ; Laravel has ~12000 files
opcache.validate_timestamps=0 ; OFF in production (manual reset)
opcache.save_comments=1 ; Required by Laravel annotations
opcache.jit=1255 ; PHP 8.2 JIT: tracing mode
opcache.jit_buffer_size=128M ; 128MB JIT buffer
(2) PHP-FPM Tuning
; php-fpm.d/www.conf
pm = dynamic
pm.max_children = 50 ; = (RAM - OS) / avg_per_process
pm.start_servers = 10 ; = min_spare + (max - min) / 2
pm.min_spare_servers = 5
pm.max_spare_servers = 20
pm.max_requests = 1000 ; Prevent memory leaks
(3) Resetting OPcache
# After deployment, reset OPcache
php artisan optimize:clear
# Or via route
Route::get('/opcache-clear', function () {
if (app()->environment('production')) {
opcache_reset();
}
return 'OPcache cleared';
})->middleware('auth:api');
(1) ▶ Example: ShopMetrics One-Click Script for Production Cache
#!/bin/bash
# deploy-optimize.sh — Run after each deployment
set -e
echo "→ Clearing all caches..."
php artisan optimize:clear
echo "→ Caching config..."
php artisan config:cache
echo "→ Caching routes..."
php artisan route:cache
echo "→ Caching views..."
php artisan view:cache
echo "→ Running migrations..."
php artisan migrate --force
echo "→ Restarting queue workers..."
php artisan queue:restart
echo "→ Clearing OPcache..."
php artisan optimize
echo "✓ Optimization complete!"
Output:
# Command executed successfully
7. Load Testing
(1) Tool Comparison
| Tools | Languages | Advantages | Use Cases |
|---|---|---|---|
| Apache Bench (ab) | C | Simple, no dependencies | Quickly stress-test a single API endpoint |
| k6 | JS | Flexible scripting, CI-friendly | Complex scenarios + CI |
| wrk | C | High Concurrency, Low Resources | Extreme Concurrency Testing |
| JMeter | Java | Rich selection of GUIs and plugins | Complex enterprise-level testing |
(2) k6 Load Testing Script
// k6-scripts/dashboard-load.js
import http from 'k6/http';
import { check, sleep } from 'k6';
export const options = {
stages: [
{ duration: '30s', target: 20 }, // Ramp up to 20 users
{ duration: '2m', target: 20 }, // Stay at 20
{ duration: '30s', target: 50 }, // Ramp up to 50
{ duration: '2m', target: 50 }, // Stay at 50
{ duration: '30s', target: 0 }, // Ramp down
],
thresholds: {
http_req_duration: ['p(95)<500'], // 95% requests < 500ms
http_req_failed: ['rate<0.01'], // Error rate < 1%
},
};
const BASE_URL = 'https://api.shopmetrics.io';
const TOKEN = __ENV.API_TOKEN;
export default function () {
const params = {
headers: { Authorization: `Bearer ${TOKEN}` },
};
// Test dashboard API
const res = http.get(`${BASE_URL}/api/v1/dashboard`, params);
check(res, {
'status is 200': (r) => r.status === 200,
'response time < 500ms': (r) => r.timings.duration < 500,
'has overview data': (r) => JSON.parse(r.body).overview !== null,
});
sleep(1);
}
(3) Quick Load Testing Commands
# Apache Bench: 100 concurrent users, 1000 total requests
ab -n 1000 -c 100 -H "Authorization: Bearer $TOKEN" \
https://api.shopmetrics.io/api/v1/dashboard
# wrk: 50 connections for 30 seconds
wrk -t4 -c50 -d30s -H "Authorization: Bearer $TOKEN" \
https://api.shopmetrics.io/api/v1/dashboard
# k6: Run load test with threshold checks
k6 run --env API_TOKEN=$TOKEN k6-scripts/dashboard-load.js
8. Comprehensive Example: Before-and-After Comparison of ShopMetrics Optimization
// ============================================
// Comprehensive: Dashboard API optimization
// Before vs After with all techniques applied
// ============================================
// ❌ BEFORE: Slow dashboard (5 seconds, 200+ queries)
class DashboardController extends Controller
{
public function index(Request $request)
{
$tenant = $request->user()->tenant;
$orders = $tenant->orders()
->whereMonth('created_at', now()->month)
->get(); // Loads ALL columns, no eager loading
return response()->json([
'total_revenue' => $orders->sum('total'),
'order_count' => $orders->count(),
'top_shops' => $orders->groupBy('shop_id') // N+1 on shop
->map->sum('total')
->sortDesc()
->take(5),
'recent_orders' => $orders->sortByDesc('created_at')
->take(10)
->map(fn ($o) => [
'id' => $o->id,
'customer' => $o->customer->name, // N+1 on customer
'total' => $o->total,
]),
]);
}
}
// ✅ AFTER: Fast dashboard (100ms, 3 queries, cached)
class DashboardController extends Controller
{
public function index(Request $request)
{
$tenant = $request->user()->tenant;
$data = Cache::remember(
"dashboard:{$tenant->id}:overview",
now()->addMinutes(15),
fn () => $this->buildDashboardData($tenant)
);
return response()->json($data);
}
private function buildDashboardData(Tenant $tenant): array
{
// Single optimized query with aggregations
$stats = DB::table('orders')
->where('tenant_id', $tenant->id)
->whereMonth('created_at', now()->month)
->select([
DB::raw('SUM(total) as total_revenue'),
DB::raw('COUNT(*) as order_count'),
])
->first();
// Top shops: 1 query
$topShops = DB::table('orders')
->join('shops', 'orders.shop_id', '=', 'shops.id')
->where('orders.tenant_id', $tenant->id)
->whereMonth('orders.created_at', now()->month)
->groupBy('shops.id', 'shops.name')
->orderByDesc('revenue')
->limit(5)
->select('shops.name', DB::raw('SUM(orders.total) as revenue'))
->get();
// Recent orders with eager loading: 1 query
$recentOrders = Order::with('customer:id,name')
->where('tenant_id', $tenant->id)
->select(['id', 'customer_id', 'total', 'created_at'])
->latest()
->limit(10)
->get()
->map(fn ($o) => [
'id' => $o->id,
'customer' => $o->customer->name,
'total' => $o->total,
]);
return [
'total_revenue' => (float) $stats->total_revenue,
'order_count' => (int) $stats->order_count,
'top_shops' => $topShops,
'recent_orders' => $recentOrders,
];
}
}
flowchart LR
subgraph "Before: 5000ms"
A1[200+ SQL Queries] --> A2[No Cache]
A2 --> A3[No Index]
A3 --> A4[N+1 Loading]
end
subgraph "After: 100ms"
B1[3 SQL Queries] --> B2[Redis Cache 15min]
B2 --> B3[Composite Index]
B3 --> B4[Eager Loading]
end
A4 -->|Optimize| B1
❓ FAQ
with()), and only use lazy loading when you're certain you won't access the associated data. Use Laravel Debugbar to monitor the number of queries—N+1 issues will be immediately apparent.cursor() uses the least memory but can only process line by line; it cannot skip lines and does not support concurrent write safety. chunkById() is more stable and suitable for batch processing in production environments. chunkById() is used in most scenarios.EXPLAIN to check whether the index is actually being used (key column); Check whether the order of the indexed columns follows the leftmost prefix principle; check whether sorting is occurring on a large result set (filesort); consider using a covering index to reduce table scans.opcache.validate_timestamps=1. Otherwise, code changes won't take effect, which can be confusing during debugging. Be sure to enable it in a production environment and set it to validate_timestamps=0.📖 Summary
- N+1 queries are a performance killer; eager loading solves the problem with a single line of code
- Use
chunkByIdorcursorwith large datasets to prevent memory overflow - Redis caches query results, using both TTL and manual expiration as a double safeguard
- Composite indexes follow the leftmost prefix principle; verified using EXPLAIN
- Optimizing OPcache + PHP-FPM to Make Your Production Environment Fly
- Validate the effectiveness of the optimization through load testing, with a target of p95 < 500 ms
📝 Exercises
-
Basic Problem (⭐): Identify any N+1 query in ShopMetrics, fix it using
with(), and compare the number of queries before and after the optimization (recorded usingDB::enableQueryLog()). -
Advanced Exercise (⭐⭐): Add Redis caching (15-minute TTL) to the ShopMetrics dashboard API and implement automatic cache flushing when data changes. Write a k6 script to test the cache hit rate.
-
Challenge (⭐⭐⭐): Fully optimize the ShopMetrics order list API—add a composite index (
tenant_id + status + created_at), replace Collection operations with DB::table aggregate queries, implement caching, and useEXPLAINto verify performance, with a target p95 < 200 ms.



