12 min read

Transform Your Laravel App Performance: 7 Quick Wins That Actually Work

Seven practical Laravel performance optimizations you can implement today, from fixing N+1 queries to processing large datasets without running out of memory.

Transform Your Laravel App Performance: 7 Quick Wins That Actually Work

Your Laravel app is slow, and you're not sure why. The pages that used to load in under a second now take three or four. Users are complaining. Your hosting bill keeps climbing. And the knee-jerk reaction is always "we need to upgrade the server."

But nine times out of ten, the problem isn't the server. It's the code. Specifically, it's a handful of easily fixable patterns that silently drain performance. I've audited dozens of Laravel applications over the years, and the same issues show up repeatedly. An N+1 query here, a missing index there, a synchronous email send that blocks the response for four seconds. Each one is small on its own, but together they compound into an app that feels sluggish.

The good news is that most of these fixes take less than an hour to implement and can cut response times dramatically. No server upgrades required.

Here are seven optimizations I apply to every Laravel project, ranked by how much impact they typically deliver.

1. Fix N+1 Queries with Eager Loading

This is the single biggest performance killer in Laravel applications. If you fix nothing else, fix this. The N+1 problem happens when you load a collection and then access a relationship on each item, triggering a separate database query for every single record.

Here's what it looks like:

// This runs 1 query to get posts + 1 query per post to get the author
// 100 posts = 101 database queries
$posts = Post::all();

foreach ($posts as $post) {
    echo $post->author->name; // Triggers a query each time
}

The fix is one word: with().

// This runs exactly 2 queries, regardless of how many posts exist
$posts = Post::with('author')->get();

foreach ($posts as $post) {
    echo $post->author->name; // Already loaded, no extra query
}

For nested relationships, chain them:

// 3 queries total: posts, authors, and comments
$posts = Post::with(['author', 'comments'])->get();

// Nested eager loading
$posts = Post::with('comments.author')->get();

You should also prevent N+1 queries from ever reaching production. Add this to your AppServiceProvider:

public function boot(): void
{
    Model::preventLazyLoading(! app()->isProduction());
}

This throws an exception in development whenever a relationship is lazy-loaded, so you catch the problem before it ships. In production, it stays silent to avoid breaking anything.

How to find N+1 queries you already have: Install Laravel Debugbar and watch the query count on each page. If you see 50+ queries on a page that displays a list, you almost certainly have an N+1 problem. I wrote a deeper guide on query optimization that covers this with before-and-after benchmarks.

2. Select Only the Columns You Need

Most developers write User::all() or Post::get() without thinking about it. But that translates to SELECT *, which pulls every column from the table, including columns you never use on that page.

On a users table with 20 columns and 10,000 rows, SELECT * transfers 200,000 field values. If you only need three columns (id, name, email), that's 170,000 wasted values. The overhead grows fast with TEXT or JSON columns that can hold kilobytes per row.

// Bad: fetches all 20 columns
$users = User::all();

// Good: fetches only what the view needs
$users = User::select(['id', 'name', 'email'])->get();

For relationships, use the same approach:

// Combine eager loading with column selection
$posts = Post::select(['id', 'title', 'author_id', 'published_at'])
    ->with('author:id,name')
    ->latest('published_at')
    ->paginate(20);

The author:id,name syntax tells Laravel to only load the id and name columns from the authors table. Always include the foreign key column (id on the related model) or the relationship won't work.

My opinion: I don't use select() on every single query. For admin panels where you need all the data, SELECT * is fine. But for public-facing pages, especially list views and API endpoints returning JSON, explicit column selection makes a real difference. On a project last year, switching a user listing endpoint from SELECT * to three specific columns dropped the response payload from 2.4MB to 180KB. The page loaded in half the time with zero functional changes.

3. Add Database Indexes Where They Matter

Missing indexes are the most common cause of slow queries in Laravel applications, and they're the easiest to fix. Without an index, the database scans every row in the table to find matches. With an index, it jumps directly to the relevant rows.

Here's a migration that adds indexes to the columns you're actually querying against:

Schema::table('orders', function (Blueprint $table) {
    $table->index('status');
    $table->index('customer_id');
    $table->index(['status', 'created_at']); // Composite index
});

The rule is simple: if a column appears in a where(), orderBy(), or join(), it probably needs an index. Check your slow queries by enabling the MySQL slow query log or using Laravel Debugbar's query tab to spot queries taking more than 100ms.

Composite indexes (multiple columns in one index) matter when you frequently filter and sort together. An index on ['status', 'created_at'] covers queries like Order::where('status', 'paid')->latest()->get() much better than two separate single-column indexes.

// This query benefits from the composite index above
$recentPaid = Order::where('status', 'paid')
    ->where('created_at', '>=', now()->subMonth())
    ->orderByDesc('created_at')
    ->paginate(25);

Don't over-index. Every index speeds up reads but slows down writes, because the database has to update the index on every INSERT and UPDATE. For tables with heavy write traffic (like activity logs), be selective. Index the columns you query against, not every column in the table. If you're working with CSV data and need to generate SQL statements for bulk operations, a CSV to SQL converter can save you time when setting up seed data for performance testing.

4. Cache Expensive Operations with Redis

Laravel ships with a powerful caching system, and Redis is the driver you should be using in production. File-based caching works for development, but Redis sits in memory and returns cached values in microseconds instead of milliseconds.

The simplest pattern is Cache::remember():

// This query runs once, then serves from cache for 30 minutes
$topProducts = Cache::remember('top-products', 1800, function () {
    return Product::with('category')
        ->where('active', true)
        ->orderByDesc('sales_count')
        ->limit(20)
        ->get();
});

For data that rarely changes, cache it longer. For data that changes frequently, cache it shorter or use cache tags for targeted invalidation:

// Cache with tags for granular control
$stats = Cache::tags(['dashboard', 'analytics'])->remember(
    'dashboard-stats',
    3600,
    fn () => $this->calculateDashboardStats(),
);

// Invalidate all dashboard caches when data changes
Cache::tags('dashboard')->flush();

Don't forget production caching commands. These should run as part of every deployment:

# Laravel 12 combines everything into one command
php artisan optimize

# This runs route:cache, config:cache, view:cache, and event:cache
# To clear everything: php artisan optimize:clear

php artisan optimize pre-compiles your routes, config, views, and events into optimized files. In a SaaS application with hundreds of routes, this alone can shave 50-100ms off every request.

Cache invalidation strategy: Use model observers to clear relevant caches when data changes. Don't cache everything with short TTLs as a band-aid for slow queries. Fix the slow queries first, then cache the results. The most common mistake I see is developers caching a query result for 60 seconds because the query is slow, instead of adding the index that would make the query fast without caching. Caching should complement good queries, not replace them.

class ProductObserver
{
    public function saved(Product $product): void
    {
        Cache::forget('top-products');
        Cache::tags('dashboard')->flush();
    }
}

5. Push Heavy Work to Queues

If an operation doesn't affect what the user sees right now, it shouldn't happen during the request. Emails, PDF generation, image processing, third-party API calls, analytics tracking: all of these should run in background queues.

Here's the difference. Without queues, the user waits for everything:

// User waits 3-5 seconds while all this happens synchronously
public function store(OrderRequest $request): JsonResponse
{
    $order = Order::create($request->validated());

    Mail::to($order->customer)->send(new OrderConfirmation($order));
    $pdf = Pdf::loadView('invoices.order', compact('order'));
    Storage::put("invoices/{$order->id}.pdf", $pdf->output());
    Http::post('https://analytics.example.com/track', [/*...*/]);

    return response()->json($order, 201);
}

With queues, the user gets an instant response:

public function store(OrderRequest $request): JsonResponse
{
    $order = Order::create($request->validated());

    ProcessOrderConfirmation::dispatch($order);

    return response()->json($order, 201);
}

The dedicated job class handles everything in the background:

class ProcessOrderConfirmation implements ShouldQueue
{
    use Queueable;

    public function __construct(
        public readonly Order $order,
    ) {}

    public function handle(): void
    {
        Mail::to($this->order->customer)
            ->send(new OrderConfirmation($this->order));

        $pdf = Pdf::loadView('invoices.order', ['order' => $this->order]);
        Storage::put("invoices/{$this->order->id}.pdf", $pdf->output());
    }

    public function failed(Throwable $e): void
    {
        Log::error("Order confirmation failed: {$this->order->id}", [
            'error' => $e->getMessage(),
        ]);
    }
}

The response time drops from 3-5 seconds to under 200ms. And the job includes retry logic and failure handling that you'd never bother adding to a synchronous controller. If the email fails because the SMTP server is temporarily down, the job retries automatically. If it fails three times, it logs the error and moves to the failed jobs table where you can investigate later.

What belongs in a queue? Anything that takes more than 100ms and doesn't affect what the user sees immediately. Email sending, PDF generation, image resizing, webhook deliveries, analytics tracking, search index updates, and third-party API calls are all prime candidates.

For a deeper look at queue architecture with Redis, Horizon, and Supervisor, check out the queue jobs guide.

6. Process Large Datasets Without Exhausting Memory

If you've ever seen a "memory exhausted" error during a data export or migration, you've hit this problem. Calling User::all() on a table with 500,000 rows loads every single record into memory at once. PHP's default 128MB limit doesn't survive that.

Laravel gives you three tools for processing large datasets efficiently:

chunk() for batch processing: Loads records in groups and frees memory between batches.

// Process 1,000 users at a time
User::where('active', true)->chunk(1000, function ($users) {
    foreach ($users as $user) {
        $user->update(['notified_at' => now()]);
    }
});

chunkById() for safe updates: Same as chunk, but uses ID-based pagination instead of offset. This avoids skipping or duplicating rows when you're modifying records during iteration.

// Safe for updates: won't skip records
User::where('subscription_expired', true)
    ->chunkById(500, function ($users) {
        foreach ($users as $user) {
            $user->update(['status' => 'inactive']);
        }
    });

lazy() for one-at-a-time processing: Uses PHP generators to load records individually. Lowest memory usage, but slower than chunking.

// Processes one record at a time, almost zero memory overhead
foreach (User::lazy() as $user) {
    // Each user is loaded individually and garbage collected
    ProcessUserExport::dispatch($user);
}

cursor() for read-only iteration: Similar to lazy(), uses a database cursor. Great for generating reports or exports where you only need to read data.

// Stream results directly to a CSV export
foreach (Order::where('year', 2025)->cursor() as $order) {
    fputcsv($file, [$order->id, $order->total, $order->created_at]);
}

Which one should you use? For updates, always use chunkById() to avoid skipping rows. For read-only processing like exports, cursor() is fastest. For dispatching jobs from a large dataset, lazy() keeps memory flat. Avoid chunk() with standard offset pagination when you're modifying the data you're iterating over, because the offset shifts as rows change.

7. Profile Before You Optimize

This isn't a specific optimization. It's the principle that makes all the others effective. Don't guess where your performance bottlenecks are. Measure them.

Install Laravel Debugbar for development. It shows you query count, query time, memory usage, and rendering time on every page load. When you see a page running 87 queries that take 1.2 seconds total, you know exactly where to focus.

composer require barryvdh/laravel-debugbar --dev

For production monitoring, Laravel Telescope gives you a dashboard for requests, queries, jobs, and exceptions:

composer require laravel/telescope
php artisan telescope:install
php artisan migrate

The key metrics to watch:

Query count per page. If a page runs more than 10-15 queries, you probably have an N+1 problem or missing eager loads. A dashboard page with 200 queries isn't normal.

Slowest queries. Sort by execution time. Any query over 100ms is worth investigating. Usually it's a missing index or a WHERE clause on an unindexed column.

Memory usage per request. A typical Laravel page should use 10-30MB. If you're seeing 80MB+, check for Model::all() calls on large tables.

Queue job duration. Slow background jobs don't affect user response time, but they create backlogs. Monitor job processing time and failure rates with Laravel Horizon if you're using Redis.

My opinion: I've seen developers spend hours micro-optimizing code paths that account for 2% of their response time while ignoring a missing database index that accounts for 80%. Profiling tells you where the actual bottleneck is. Always measure first, optimize second.

FAQ

Which optimization gives the biggest performance improvement?

Fixing N+1 queries consistently delivers the largest improvement. I've seen pages go from 4 seconds to 200ms just by adding with() calls to existing queries. Database indexing is second. Caching is third. Everything else depends on your specific application.

Should I use Laravel Octane for better performance?

Octane keeps your application booted in memory between requests, which eliminates the framework bootstrap overhead. It's worth considering if you're running a high-traffic API or a real-time application. But it introduces complexity around memory leaks and state management. For most applications, fixing queries, adding caching, and using queues delivers more improvement with less risk than adopting Octane.

How do I know if my Laravel app has performance problems?

Install Laravel Debugbar and check three things: query count per page (should be under 15 for most pages), total query time (should be under 200ms), and memory usage (should be under 30MB for typical pages). If any of these are significantly higher, you have optimization opportunities.

Is Redis required for Laravel caching?

No. Laravel supports file, database, Memcached, and Redis cache drivers. But Redis is the recommended choice for production because it stores data in memory and supports cache tags, which let you invalidate groups of cached values at once. For local development, the file or array driver works fine.

How often should I run performance audits?

I run a quick performance check (Debugbar query counts on key pages) after every major feature addition. A full audit (slow query analysis, cache hit rates, queue performance) every quarter or when response times start creeping up. Performance degrades gradually as features accumulate, so regular checks prevent you from waking up to a slow application one day.

Wrapping Up

Performance optimization isn't a one-time project. It's a habit you build into your development workflow. The seven techniques here cover the issues I see most frequently in Laravel audits: N+1 queries, missing column selection, absent indexes, no caching strategy, synchronous heavy operations, memory-hungry data processing, and optimizing without measurement.

Start with profiling. Install Debugbar, find your slowest pages, and fix the biggest bottleneck first. That single fix often delivers more improvement than all the other optimizations combined. Then work your way down the list. Add eager loading to your relationship queries. Throw indexes on your most-queried columns. Push email sending to a queue. Each fix compounds on the last.

The best part? None of these require a major rewrite. They're incremental improvements you can ship in a single afternoon.

If your Laravel application needs a performance audit and you want hands-on help identifying and fixing bottlenecks, let's talk about it.

Share: X/Twitter | LinkedIn | | RSS
Hafiz Riaz

About Hafiz

Senior Full Stack Developer. I build production software with Laravel, Filament, Vue, and AI integrations, and write about the real decisions behind shipping it.

Get in touch →

Get web development tips via email

Join 50+ developers • No spam • Unsubscribe anytime