Development
Jun 5, 2025
12 min read
9 views

Laravel Performance Optimization: From Slow to Lightning Fast

Transform your Laravel application from sluggish to lightning-fast. Learn database optimization, caching strategies, and performance monitoring techniques that scale.

Laravel Performance Optimization: From Slow to Lightning Fast

Share this post

Performance can make or break your Laravel application. Over time, I've learned that good performance isn't just about fast code - it's about smart architecture, efficient queries, and strategic caching. Here are some patterns that have worked well for keeping Laravel apps running smoothly.

Performance optimization should be data-driven. Always measure before and after changes using real production data.

The Performance Pyramid

Database

60-80% of performance issues start here

Caching

Fastest way to improve response times

Code

Optimize algorithms and logic

Infrastructure

Scale when optimization isn't enough

Database Performance Mastery

1. Eliminate N+1 Queries

The most common performance killer in Laravel applications:

❌ Problematic Code

// This generates N+1 queries!
$posts = Post::all();
foreach ($posts as $post) {
    echo $post->category->name; // Query for each post
    echo $post->author->name;   // Another query for each post
}

✅ Optimized Code

// This generates only 3 queries total
$posts = Post::with(['category', 'author'])->get();
foreach ($posts as $post) {
    echo $post->category->name; // No additional query
    echo $post->author->name;   // No additional query
}

2. Strategic Database Indexing

Proper indexing can turn slow queries into lightning-fast ones:

<?php
// Migration with performance-focused indexes
Schema::create('blog_posts', function (Blueprint $table) {
    $table->id();
    $table->string('title');
    $table->string('slug')->unique();
    $table->text('content');
    $table->enum('status', ['draft', 'published']);
    $table->timestamp('published_at')->nullable();
    $table->foreignId('category_id')->constrained();
    $table->foreignId('user_id')->constrained();
    $table->timestamps();

    // Compound indexes for common query patterns
    $table->index(['status', 'published_at']); // For published posts
    $table->index(['user_id', 'status']);      // For user's posts
    $table->index(['category_id', 'status']);  // For category filtering
    
    // Full-text search
    $table->fullText(['title', 'content']);
});

3. Query Optimization Techniques

Use Select Specific Columns

// Instead of loading all columns
$posts = Post::all();

// Select only what you need
$posts = Post::select(['id', 'title', 'slug', 'published_at'])->get();

Optimize Pagination

// Use cursor pagination for large datasets
$posts = Post::orderBy('id')->cursorPaginate(20);

// Or use simplePaginate when you don't need page numbers
$posts = Post::simplePaginate(20);

Chunk Large Operations

// Process large datasets in chunks
Post::chunk(1000, function ($posts) {
    foreach ($posts as $post) {
        // Process each post
        $this->processPost($post);
    }
});

Advanced Caching Strategies

1. Multi-Layer Caching Architecture

<?php
class PostService
{
    public function getPopularPosts(int $limit = 10): Collection
    {
        return Cache::remember(
            "popular_posts_{$limit}",
            now()->addHours(6),
            function () use ($limit) {
                return Post::withCount('views')
                    ->orderBy('views_count', 'desc')
                    ->limit($limit)
                    ->get();
            }
        );
    }

    public function getPostBySlug(string $slug): Post
    {
        // Cache individual posts for 24 hours
        return Cache::remember(
            "post_slug_{$slug}",
            now()->addDay(),
            fn() => Post::where('slug', $slug)
                        ->with(['category', 'author', 'tags'])
                        ->firstOrFail()
        );
    }

    public function invalidatePostCache(Post $post): void
    {
        // Clear related caches when post is updated
        Cache::forget("post_slug_{$post->slug}");
        Cache::forget("post_id_{$post->id}");
        Cache::tags(['posts', 'homepage'])->flush();
    }
}

2. Redis Optimization

Configure Redis for optimal Laravel performance:

<?php
// 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'), '_')),
        'serializer' => 'php', // Faster than JSON
        'compression' => 'lz4', // Compress large values
    ],

    'default' => [
        'url' => env('REDIS_URL'),
        'host' => env('REDIS_HOST', '127.0.0.1'),
        'port' => env('REDIS_PORT', '6379'),
        'database' => env('REDIS_DB', '0'),
        'read_write_timeout' => 60,
        'persistent_connections' => true,
    ],

    // Separate database for sessions
    'sessions' => [
        'host' => env('REDIS_HOST', '127.0.0.1'),
        'port' => env('REDIS_PORT', '6379'),
        'database' => env('REDIS_SESSION_DB', '1'),
    ],

    // Separate database for cache
    'cache' => [
        'host' => env('REDIS_HOST', '127.0.0.1'),
        'port' => env('REDIS_PORT', '6379'),
        'database' => env('REDIS_CACHE_DB', '2'),
    ],
],

3. HTTP Caching with ETags

<?php
class PostController extends Controller
{
    public function show(Post $post)
    {
        // Generate ETag based on post and its relationships
        $etag = md5($post->updated_at . $post->category->updated_at);
        
        if (request()->header('If-None-Match') === $etag) {
            return response(null, 304); // Not Modified
        }

        return response()
            ->view('posts.show', compact('post'))
            ->header('ETag', $etag)
            ->header('Cache-Control', 'public, max-age=3600');
    }
}

Code-Level Optimizations

1. Efficient Collection Operations

❌ Inefficient

$posts = Post::all();

// Multiple loops through the same collection
$published = $posts->filter(fn($p) => $p->published);
$categories = $posts->map(fn($p) => $p->category);
$titles = $posts->pluck('title');

✅ Efficient

$posts = Post::all();

// Single loop with reduce or transform
[$published, $categories, $titles] = $posts->reduce(
    function ($carry, $post) {
        if ($post->published) $carry[0][] = $post;
        $carry[1][] = $post->category;
        $carry[2][] = $post->title;
        return $carry;
    },
    [[], [], []]
);

2. Lazy Loading and Generators

<?php
class ExportService
{
    public function exportPosts(): Generator
    {
        // Use lazy collections for memory efficiency
        return Post::lazy()->map(function ($post) {
            return [
                'title' => $post->title,
                'author' => $post->author->name,
                'published' => $post->published_at->format('Y-m-d'),
            ];
        });
    }

    public function processLargeDataset(): void
    {
        // Process without loading everything into memory
        Post::lazy()->each(function ($post) {
            $this->processPost($post);
            
            // Optional: Pause between batches to reduce load
            if ($post->id % 1000 === 0) {
                sleep(1);
            }
        });
    }
}

Queue Optimization

Batch Processing for Heavy Operations

<?php
use Illuminate\Bus\Batch;
use Illuminate\Support\Facades\Bus;

class NewsletterService
{
    public function sendToAllSubscribers(Newsletter $newsletter): void
    {
        $subscribers = User::whereNotNull('email_verified_at')
            ->chunk(100)
            ->map(fn($users) => new SendNewsletterJob($newsletter, $users));

        // Process in batches with progress tracking
        Bus::batch($subscribers)
            ->then(function (Batch $batch) {
                Log::info('Newsletter sent to all subscribers');
            })
            ->catch(function (Batch $batch, Throwable $e) {
                Log::error('Newsletter batch failed', ['error' => $e->getMessage()]);
            })
            ->finally(function (Batch $batch) {
                Cache::forget('newsletter_sending');
            })
            ->dispatch();
    }
}

Performance Monitoring

1. Laravel Telescope Configuration

<?php
// config/telescope.php
'watchers' => [
    Watchers\QueryWatcher::class => [
        'enabled' => env('TELESCOPE_QUERY_WATCHER', true),
        'slow' => 100, // milliseconds
    ],

    Watchers\RequestWatcher::class => [
        'enabled' => env('TELESCOPE_REQUEST_WATCHER', true),
        'size_limit' => env('TELESCOPE_RESPONSE_SIZE_LIMIT', 64),
    ],

    Watchers\CacheWatcher::class => [
        'enabled' => env('TELESCOPE_CACHE_WATCHER', true),
    ],
],

2. Custom Performance Middleware

<?php
class PerformanceMonitoringMiddleware
{
    public function handle(Request $request, Closure $next): Response
    {
        $startTime = microtime(true);
        $startMemory = memory_get_usage();

        $response = $next($request);

        $duration = microtime(true) - $startTime;
        $memoryUsed = memory_get_usage() - $startMemory;

        // Log slow requests
        if ($duration > 2.0) {
            Log::warning('Slow request detected', [
                'url' => $request->fullUrl(),
                'method' => $request->method(),
                'duration' => round($duration, 3),
                'memory' => $this->formatBytes($memoryUsed),
                'queries' => DB::getQueryLog(),
            ]);
        }

        return $response->withHeaders([
            'X-Response-Time' => round($duration * 1000, 2) . 'ms',
            'X-Memory-Usage' => $this->formatBytes($memoryUsed),
        ]);
    }

    private function formatBytes(int $bytes): string
    {
        return round($bytes / 1024 / 1024, 2) . 'MB';
    }
}

Production Configuration

Optimized php.ini Settings

[PHP]
; Production performance settings
memory_limit = 256M
max_execution_time = 30
max_input_vars = 3000

; OPcache (critical for performance)
opcache.enable = 1
opcache.memory_consumption = 256
opcache.max_accelerated_files = 20000
opcache.revalidate_freq = 0
opcache.validate_timestamps = 0
opcache.save_comments = 0
opcache.fast_shutdown = 1
opcache.enable_file_override = 1

; Realpath cache
realpath_cache_size = 4096K
realpath_cache_ttl = 600

; Session handling
session.gc_probability = 0  # Handle via Laravel schedule

Laravel Production Optimizations

# Essential production commands
php artisan config:cache    # Cache configuration
php artisan route:cache     # Cache routes  
php artisan view:cache      # Cache Blade templates
php artisan event:cache     # Cache events and listeners

# Optimize Composer autoloader
composer install --optimize-autoloader --no-dev

# Clear unnecessary caches
php artisan optimize:clear

# Generate optimized files
php artisan optimize

Performance Testing & Benchmarking

🔍 Essential Performance Metrics

Response Time Targets:

  • • Homepage: < 500ms
  • • Category pages: < 800ms
  • • Search results: < 1000ms
  • • Admin pages: < 1500ms

Database Targets:

  • • Query time: < 100ms
  • • Queries per request: < 20
  • • No N+1 queries
  • • Index usage: > 95%

Load Testing with Artillery

# artillery-config.yml
config:
  target: 'https://your-app.com'
  phases:
    - duration: 60
      arrivalRate: 10  # 10 users per second
    - duration: 120
      arrivalRate: 50  # Ramp up to 50 users per second
    - duration: 60
      arrivalRate: 100 # Peak load

scenarios:
  - name: 'Browse blog'
    weight: 70
    flow:
      - get:
          url: '/'
      - get:
          url: '/blog'
      - get:
          url: '/blog/{{ $randomString() }}'

  - name: 'Search and filter'
    weight: 30
    flow:
      - get:
          url: '/search?q=laravel'
      - get:
          url: '/blog?category=development'

Common Performance Anti-Patterns

❌ Loading Entire Models in Loops

foreach ($userIds as $id) {
    $user = User::find($id); // Separate query for each user
}

Fix: Use User::whereIn('id', $userIds)->get()

❌ Unnecessary Model Instantiation

$count = User::all()->count(); // Loads all users into memory

Fix: Use User::count() for database-level counting

❌ Missing Database Indexes

Post::where('status', 'published')->where('category_id', $id)->get();

Fix: Add compound index on ['status', 'category_id']

Conclusion: The Performance Mindset

Performance optimization isn't a one-time task - it's a mindset that should permeate every aspect of your Laravel development. Start with the database, leverage caching strategically, and always measure the impact of your changes.

Remember: premature optimization is the root of all evil, but so is ignoring performance until it's too late. Build performance monitoring into your development process from day one, and your future self (and users) will thank you.

Performance Optimization Checklist

Database:

  • ✓ Eliminate N+1 queries
  • ✓ Add proper indexes
  • ✓ Optimize slow queries
  • ✓ Use pagination

Application:

  • ✓ Implement caching layers
  • ✓ Optimize collection operations
  • ✓ Use queues for heavy tasks
  • ✓ Monitor performance metrics

Struggling with application performance issues? Let's chat about optimizing your Laravel application for scale and speed. I've helped teams improve response times by 10x while reducing server costs.

Chris Page

Chris Page

Fractional CTO and Software Engineer with 25+ years of experience. I help startups scale from 0 to 7 figures using AI-assisted development and proven frameworks.

Related Posts

Continue reading

Ready to Scale Your Startup?

Let's discuss how I can help you build your MVP or optimize your existing technology stack.