How I Optimize Laravel Performance in Production - hero image showing Laravel performance tuning under real production load

How I Optimize Laravel Performance in Production

Rahmat Ullah profile photoRahmat Ullah
9 min readEngineering Insights, Laravel, Performance

Most Laravel apps don't fail because of bad code. They fail because they get slow.

The dashboard that loaded in 400ms during development now takes six seconds under real traffic. The "instant" search endpoint times out for any user with more than a few hundred records. CPU sits at 12% but the database is melting. Nothing is broken. Everything is just slow enough to feel broken.

This is the post-structure problem. I wrote about the mistakes I stopped making and the structure I land on by default. Structure makes a Laravel app maintainable. It does not make it fast. That happens at a different layer, and it happens after the app is already in production.

This is where most Laravel apps start to fall apart, not in code, but under load.

The Mindset Shift

The first thing that changes when you optimize Laravel apps under real load is what you stop doing.

You stop optimizing everything. Most of the code in a Laravel app does not run on the hot path. Optimizing a controller that serves 30 requests a day is wasted effort. The user does not feel it.

You start optimizing what users feel. Response time on the endpoints they hit constantly. API latency on the calls that block the UI. The dashboard that loads on every login. Those are the only things that matter.

And you stop arguing about code style. Performance is about bottlenecks, not aesthetics. A "clean" 12-query method is slower than an "ugly" 1-query method, every time.


Where Laravel Apps Actually Get Slow

After enough production fires, the problem is almost always one of four places.

Database

This is where the bleeding usually starts. N+1 queries from a forgotten with() call. Missing indexes on the columns your where clauses actually filter by. Heavy joins that scan a million rows to return ten. The query log will tell you which one in 30 seconds, but only if you bother to look.

Caching

Cache misuse goes both ways. Some teams cache nothing, then wonder why every request hits Redis four times and Postgres twelve. Other teams cache everything, then ship a stale-data bug into production because they cannot remember what they invalidated where. Both are problems.

API layer

Every API I have inherited has a payload problem. A user list endpoint that returns 80 fields when the UI uses six. A with(['everything']) chain that hydrates four levels of relationships nobody renders. Bandwidth is cheap until it is not.

Background jobs

Synchronous work that should not be synchronous. Sending email inside a request. Generating a PDF inside a request. Hitting a third-party webhook inside a request. Every one of those is a foot-gun aimed at your p95.


What I Actually Optimize

These are the levers I reach for, in this order, because they actually move the needle.

Database optimization

Eager loading is not optional. Any controller that loads a collection and accesses a relationship inside a Blade view or Resource is N+1 unless you proved otherwise. with(), load(), and withCount() cover 90% of cases.

Indexes follow query patterns, not schemas. I add indexes after I see the query plan, not before. EXPLAIN is the only thing that tells you the truth.

Query tuning over query rewriting. Most slow queries are not slow because of structure. They are slow because they scan instead of seek. Composite indexes on (tenant_id, created_at) tend to fix more dashboards than any refactor.

// Before: 1 + N queries on a 200-row list
$orders = Order::all();
foreach ($orders as $order) {
    echo $order->customer->name;
}

// After: 2 queries, regardless of list size
$orders = Order::with('customer')->get();

Caching strategy

I cache things that are expensive to compute and stable enough to stay valid. Aggregations, denormalized counters, third-party API responses, anything that requires a 200ms query to produce.

I do not cache things that change every request, are cheap to recompute, or whose invalidation logic is harder than the original query. If you cannot describe the invalidation rule in one sentence, you are about to ship a bug.

$revenue = Cache::remember(
    "tenant:{$tenant->id}:revenue:today",
    now()->addMinutes(5),
    fn () => $this->reports->dailyRevenue($tenant),
);

TTLs are decisions, not defaults. Five minutes for dashboard metrics. One hour for category trees. A day for currency exchange rates. "Forever" is almost always wrong.

Async processing

Anything that is not strictly required to produce the response goes to a queue. Emails. Invoices. Webhook fan-out. Report generation. Image processing. The HTTP request returns in 80ms; the rest happens on a worker that nobody is staring at.

API optimization

Pagination by default, even on endpoints you "know" will be small. Limit fields explicitly through API Resources. Drop relationships the client does not use. A 4MB JSON response is a performance bug, not a feature.


A Real Production Example

A reporting dashboard endpoint on a B2B SaaS, taking 4–6 seconds at p95. Customers were starting to complain.

The endpoint did three things: load the user's tenant, run a revenue aggregation, and load the last 50 orders with their line items.

The query log told the story in seconds: 1 query for the tenant, 1 for the aggregation, 1 for the orders, and 50 separate queries hydrating line items per order. The aggregation alone scanned the entire orders table because there was no index on (tenant_id, created_at).

Three changes, in this order:

  1. Added a composite index on (tenant_id, created_at). Aggregation dropped from 2.1s to 90ms.
  2. Eager-loaded line items via with('lineItems'). The 50-query tail collapsed into one.
  3. Cached the revenue aggregation for 5 minutes. Repeat loads stopped touching the DB at all.

Result: 5.2s → ~280ms at p95. No code rewrite. No architectural change. Three targeted fixes guided by the query log.

The lesson is not the specific fixes. It is the order: measure, then index, then eager-load, then cache. Reverse that order and you ship a cache that hides a missing index for six months until the cache layer goes down.


What I Don't Optimize

A short list, because it matters more than people admit.

  • I don't micro-optimize PHP. Whether you used array_map or a foreach is irrelevant when one query is doing 400ms of disk I/O.
  • I don't pre-cache things the app does not actually need fast. Caching a query that runs once per day is theater.
  • I don't introduce abstraction layers for performance reasons. Repositories, query builders, custom collections. None of them make the database faster. They just make the bottleneck harder to find.
  • I don't optimize before measuring. Every "obvious" hotspot I have ever guessed at has been wrong about half the time. Telescope, the slow query log, and APM data are non-negotiable.

Key Principles I Follow

  • Optimize bottlenecks, not everything. Most of the code does not matter.
  • Measure before you change anything. Guesses are 50/50; profiles are not.
  • Cache what is expensive and stable. Skip what is cheap or volatile.
  • Move heavy work to queues. The HTTP path is for what the user is waiting on.
  • Indexes follow real queries. Add them after seeing the plan, not the schema.

These are the same four levers, in roughly the same order, on every Laravel app I have shipped. The names of the tables change. The pattern does not.


Closing Thoughts

Laravel performance optimization is rarely about clever code. It is about removing the slow parts users actually feel, and ignoring the rest. Scalable Laravel applications are not the ones with the most layers; they are the ones where the hot path is honest about what it is doing.

The structure from the last post buys you maintainability. These techniques buy you the headroom to grow into it. Together, they are most of what "production-ready Laravel" actually means.

Performance is not about writing clever code. It's about removing the slow parts users actually feel.

If you've worked with Laravel in production, what was your biggest performance bottleneck?