Back to Blogs
2026-07-08 Abdelrhman Yasser

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:

DriverSpeedPersistenceBest For
Redis⚡⚡⚡⚡⚡YesProduction, sessions, cache, queues
Memcached⚡⚡⚡⚡⚡YesLarge caches, simple key-value
Database⚡⚡YesDevelopment, shared hosting
FileYesDevelopment, 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

MethodMemoryUse Case
cursor()~1KBSimple read-only iteration
lazy()~1KB + model overheadWhen you need models
chunk()Scales with chunk sizeWhen you need to update records
chunkById()Scales with chunk sizeWhen 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:

AspectTraditionalOctane
MemoryFresh per requestShared
Boot timeFull bootstrap every requestOnce
Static stateReset naturallyMust be manually reset
Singleton behaviorPer-request singletonGlobal 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

ToolTypeCost
Laravel PulseSelf-hosted, freeFree
Laravel HorizonQueue monitoringFree
SentryError trackingFreemium
New RelicAPMPaid
Scout APMAPMPaid

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

ScenarioUnoptimizedOptimizedImprovement
Homepage load450ms45ms10x
API list (1000 records)1200ms35ms34x
Email order confirmation600ms5ms120x
Report generation (50k rows)30s2s15x
Concurrent users (same hardware)100200020x

Interview Questions

  1. What is OPcache and how do you configure it for Laravel?
  2. Explain the different cache drivers and when to use each.
  3. How does Laravel Octane work and what are its trade-offs?
  4. What queue configuration would you use for a high-traffic application?
  5. How do you identify and fix N+1 queries in production?
  6. What is the difference between cursor() and chunk()?
  7. How would you benchmark a Laravel application?

Related Articles

# Laravel# PHP# Performance# Optimization# Production# Caching
WhatsApp
Chat on WhatsApp