Skip to content

Laravel performance tips that actually move the needle

By SunnyKumar Jonwal 10 min read

Laravel apps rarely get slow because of anything exotic. When a page takes four seconds, the cause is almost always one of a small set of familiar problems, and the fixes are short. What goes wrong is that people guess, tweak settings that don't matter, and never measure.

This post goes through the issues I'd check, roughly in the order I'd check them, with code you can use. It assumes Laravel 11 or 12 on a recent PHP, and a relational database such as MySQL. The principle holds across versions: measure first, change one thing, measure again.

Measure before you touch anything

You can't improve what you haven't timed. A few tools make the invisible visible.

Laravel Debugbar shows queries, timing, and memory for each request in local development. Laravel Telescope records requests, queries, jobs, and exceptions, and works well on a staging server. For production, use whatever application performance monitoring your hosting setup supports, and at a minimum log slow queries at the database level.

If you want a quick look without installing anything, listen for queries yourself. In a service provider's boot method, while debugging:

use Illuminate\Support\Facades\DB;

DB::listen(function ($query) {
    if ($query->time > 100) { // milliseconds
        logger()->warning('Slow query', [
            'sql' => $query->sql,
            'ms' => $query->time,
        ]);
    }
});

Run the slow page and read the log. The offenders usually stand out, and they're often not the ones you suspected.

Kill N+1 queries

This is the most common Laravel performance problem. It happens when you load a list and then touch a relationship on each item, which fires one extra query per item.

// 1 query for posts, then 1 query per post for its author
$posts = Post::latest()->take(50)->get();

foreach ($posts as $post) {
    echo $post->author->name;
}

With 50 posts that's 51 queries. Eager load the relationship and it becomes 2:

$posts = Post::with('author')->latest()->take(50)->get();

You can load only the columns you need, which matters for wide tables. Remember to include the key columns that Laravel needs to match the rows up:

$posts = Post::with('author:id,name')
    ->select('id', 'title', 'author_id', 'published_at')
    ->latest()
    ->take(50)
    ->get();

The best protection is to make lazy loading fail loudly during development, so you can't ship an N+1 by accident. In AppServiceProvider::boot:

use Illuminate\Database\Eloquent\Model;

Model::preventLazyLoading(! app()->isProduction());

Now any lazy-loaded relationship throws an exception in local and staging environments, and production stays quiet. It's a small line that catches a whole class of problems. Laravel also offers Model::shouldBeStrict() which bundles this with other strictness checks, if you like having a few more guardrails.

For counts, don't load a relationship just to count it:

// Loads every comment into memory
$count = $post->comments->count();

// One aggregate query
$count = $post->comments()->count();

// Counts for a whole list in a single extra query
$posts = Post::withCount('comments')->get(); // $post->comments_count

Add the right indexes

A query that scans a million rows to find ten is slow no matter how clean your PHP is. Indexes are how the database finds rows fast, and the rule of thumb is simple: index the columns you filter, join, and sort on.

Ask the database what it's doing. In MySQL, put EXPLAIN in front of the query:

EXPLAIN SELECT * FROM orders WHERE customer_id = 42 ORDER BY created_at DESC LIMIT 20;

Look at the type and rows columns. A type of ALL means a full table scan, and a big rows number means the database examined a lot of data. After adding a suitable index, you want to see it used, and the rows examined should drop sharply.

In a migration, a composite index that matches the query's filter and sort works well:

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

Column order matters: put the equality-filtered column first, then the sort column. Foreign key columns usually deserve an index too, and Laravel's foreignId()->constrained() creates one for you in most databases. Don't index everything, though. Each index slows writes and takes space, so add them for queries you actually run.

Don't load more than you need

Fetching an entire table into memory is another common mistake, often hiding in a command that worked fine until the data grew.

For large jobs, process rows in chunks. Use chunkById for anything you update while iterating, because it's safe against rows shifting under you:

User::query()
    ->where('newsletter', true)
    ->chunkById(500, function ($users) {
        foreach ($users as $user) {
            // process each user
        }
    });

For huge exports where you only need to read, cursor() streams rows one at a time using a single query and low memory:

foreach (Order::query()->where('year', 2026)->cursor() as $order) {
    // write a row to the CSV
}

For lists shown to users, paginate. If you don't need the total count, simplePaginate skips the expensive COUNT(*) query and just offers next and previous links, which is a good fit for infinite scroll and large tables.

Cache what's expensive and slow to change

Some results are costly to compute and identical for many requests: a homepage's featured items, a dashboard's totals, the output of a slow third-party API. Cache::remember handles that in one expression:

use Illuminate\Support\Facades\Cache;

$featured = Cache::remember('home.featured', now()->addMinutes(10), function () {
    return Post::published()->where('featured', true)->with('author:id,name')->get();
});

Choose a lifetime that matches how stale the data can be. When data changes, clear the key explicitly, for example in a model observer or after an admin save:

Cache::forget('home.featured');

Use a real cache driver in production. The file driver works for small sites, and Redis or Memcached handle load better and support atomic operations. Cache invalidation is famously tricky, so cache things whose staleness you can tolerate, and avoid caching anything per-user unless you include the user in the key.

Move slow work out of the request

If a request sends email, resizes images, calls a slow API, or generates a PDF, the user waits for all of it. Put that work on a queue and respond right away:

// In the controller: return fast
SendInvoiceEmail::dispatch($order);

return back()->with('status', 'Invoice on its way.');
class SendInvoiceEmail implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    public function __construct(public Order $order) {}

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

You need a worker process running (php artisan queue:work, managed by Supervisor or systemd in production) and a driver other than sync. Database, Redis, and SQS all work. Make jobs safe to run twice, because retries happen, and set sensible tries and backoff values.

This also applies to calls to AI models, which are slow by web standards. The pattern in add Claude to a Laravel app with the HTTP client uses a queued job for exactly this reason.

Use the production caches

Laravel can pre-compute things that it would otherwise work out on every request. On a production server, after each deploy, run:

php artisan optimize

That single command caches the configuration, routes, events, and views. You can also run the individual commands, config:cache, route:cache, event:cache, and view:cache. Two cautions are worth knowing. After config:cache, the env() helper only works inside config files, so read settings through config() in your application code, or you'll get nulls in production. And cached routes go stale if you forget to rebuild them after a deploy: new routes will 404 or error until you run php artisan route:clear and then cache them again. That bit me on this very site, where a freshly deployed route returned 500 errors until the route cache was rebuilt, so I put the clear-and-cache step into the deploy routine.

Also make sure PHP's OPcache is enabled on the server. It stores compiled PHP in memory and is one of the cheapest large speedups available. Check it with php -i | grep opcache.enable. Install dependencies with composer install --no-dev --optimize-autoloader for production, so the autoloader is optimized and development packages aren't loaded.

Trim the front end

Server time isn't the whole story. A page can render in 100 milliseconds on the server and still feel slow because of what the browser has to download. Build assets with Vite for production (npm run build), so they're minified and fingerprinted. Serve them with long cache headers and compression from your web server, and use correctly sized images in modern formats. Defer scripts that aren't needed for the first paint. Browser developer tools and Lighthouse will tell you where the weight is.

Mistakes I'd look for in a code review

Some patterns show up in slow Laravel code often enough that I scan for them by habit. A query inside a loop, which is the N+1 pattern in disguise, sometimes hidden behind an accessor or a helper method that quietly runs a query. A ->get() followed by filtering in PHP, when a where clause would let the database do it. Model::all() on a table that will grow. Counting with ->get()->count() instead of ->count(). A whereHas on a large table without an index on the foreign key, which can produce slow subqueries.

On the response side, I look for big JSON resources that serialize every attribute and relationship when the client needs three fields, and for endpoints that return unpaginated lists. In views, I check for relationship access inside Blade loops, since that's where lazy loading slips in unnoticed.

None of these is hard to fix, and a short checklist in your pull request template helps reviewers catch them before they ship.

What changes when one server isn't enough

Most applications never outgrow a single decent server, and a well-tuned one handles far more traffic than people expect. If you do reach the limit, the changes are mostly about moving state out of the web server so that you can run several copies. Sessions and cache move to Redis or the database. File uploads move to object storage. Queues run on dedicated workers. The database may get a read replica, and Laravel supports separate read and write connections in configuration.

Before you scale out, check that you're not paying for a problem you could fix. Adding servers to cover N+1 queries just multiplies the waste. Fix the queries first, then see whether you still need more capacity.

A short order of operations

When an app feels slow, here's how I'd work through it:

  1. Measure: find the slowest pages and the queries behind them.
  2. Fix N+1 problems and turn on preventLazyLoading so they stay fixed.
  3. Add indexes for the slow queries, confirming with EXPLAIN.
  4. Stop loading more data than you need: select columns, chunk, paginate.
  5. Cache results that are expensive and tolerant of staleness.
  6. Queue the slow side effects.
  7. Run php artisan optimize and enable OPcache on production.
  8. Measure again, and keep the numbers.

Most apps get most of their gains from steps two and three. The rest is polish, and it's worth doing in order, since the cheap fixes pay for themselves first.

Keeping it fast over time

Performance work decays. Someone adds a feature, a relationship gets touched inside a loop, and the page you tuned in March is slow again by June. Two cheap habits slow that decay. Write a test for your heaviest pages that asserts how many queries they run, using Laravel's query logging or an assertion helper, so a new N+1 fails the build. And keep a simple record of response times for your slowest routes, so a regression shows up as a number and not as a vague complaint.

A note on guessing

The biggest time-waster in performance work is optimizing by feel. Someone suspects the framework, someone else blames the server, and a week goes into microbenchmarks of code that takes 2 milliseconds. A slow query log and ten minutes with EXPLAIN usually end the argument. Let the measurements pick the target, change one thing at a time, and keep the ones that help.