404 Not Found

404 Not Found


nginx

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


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.

TEXT
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

PHP
// ❌ 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

PHP
// ❌ 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:

TEXT
// Execution Successful

(2) ▶ Example: Using chunks and cursors with large datasets

PHP
// 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:

TEXT
// Execution Successful

4. Caching Strategy

(1) Laravel Caching Hierarchy

100%
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

PHP
// 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:

TEXT
// Execution Successful

(3) Cache Tags and Batch Expiration

PHP
// 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

PHP
// 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

SQL
-- 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

SQL
-- ❌ 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:

TEXT
CREATE TABLE

(4) Read-Write Separation

PHP
// 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

INI
; 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

INI
; 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

BASH
# 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

BASH
#!/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:

TEXT
# 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

JAVASCRIPT
// 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

BASH
# 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

PHP
// ============================================
// 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,
        ];
    }
}
100%
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

Q When should you use caching?
A Only cache data that is read frequently but written infrequently. Reports, dashboards, and statistical data are suitable for caching (15-60-minute TTL); real-time data should not be cached. Caching must include an expiration policy—data must be proactively cleared when it changes.
Q How do I choose between eager loading and lazy loading?
A Use eager loading by default (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.
Q Which is better, chunk or cursor?
A 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.
Q What should I do if the query is still slow even after adding an index?
A Use 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.
Q Should OPcache be enabled in a development environment?
A Do not enable it in a development environment, or set it to 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.
Q How many concurrent users should be used for load testing?
A Start by testing with 2 to 5 times the expected peak. For example, if you have 1,000 daily active users and a peak of about 100 concurrent users, test with 200-500 concurrent users. Focus on the p95 and p99 latency values, not the average.

📖 Summary


📝 Exercises

  1. 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 using DB::enableQueryLog()).

  2. 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.

  3. 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 use EXPLAIN to verify performance, with a target p95 < 200 ms.

Web-Tutorial.com

Web-Tutorial Tech Team

A team of developers maintaining programming tutorials. Each tutorial is written and reviewed by developers with expertise in that field. We work to keep our content accurate and reliable — if you spot an issue, please let us know.

100%

🙏 帮我们做得更好

我们是刚上线的编程教程站,几个人的小团队,精力有限。页面虽经检查,难免还有疏漏——链接失效、排版错乱、内容有误、语言生硬……

如果您发现了,麻烦告诉我们,我们会在收到反馈后第一时间进行修复,再次感谢您的光临 🙏