The real damage usually comes from lazy loading, synchronous work, and optional caching.

The page takes four seconds to render, somebody opens the profiler, and PHP gets blamed before the first slow query even finishes scrolling past.
That accusation sounds sophisticated because PHP is old enough to collect prejudice. It is also usually lazy.
Most slow Laravel apps are not suffering from “PHP being slow” in any useful diagnostic sense. They are suffering from three ordinary engineering decisions: letting Eloquent discover relationships one query at a time, keeping too much work inside the request cycle, and treating caching as a future optimization instead of part of the design.
PHP gets blamed because it is the runtime you can name. These decisions are harder because they implicate your code, your data shape, and your willingness to define what must be fast before production defines it for you.
The first bad decision is letting Eloquent be polite
Laravel’s Eloquent relationships are pleasant to use, which is exactly why they get teams into trouble.
Laravel’s docs say relationship properties are lazy loaded by default. That means the related data is not loaded until you first access the relationship. The same docs also say eager loading alleviates the N + 1 query problem. That is not a small footnote. That is one of the most common reasons a Laravel endpoint feels fine with ten rows and embarrassing with two hundred.
The pattern usually starts out looking harmless:
$orders = Order::latest()->take(50)->get();
foreach ($orders as $order) {
echo $order->customer->name;
echo $order->items->count();
}That code reads cleanly, but it also gives the database a chance to answer the same basic question over and over again. One query for the orders, then more queries for customers, then more queries for items. Repeat until your response time becomes a relationship graph.
The fix is not exotic:
$orders = Order::query()
->with(['customer', 'items'])
->latest()
->take(50)
->get();Now the application is making a decision instead of improvising one. You are telling Laravel what the endpoint actually needs up front.
This is also why I like turning on lazy-loading protection outside production:
use Illuminate\Database\Eloquent\Model;
public function boot(): void
{
Model::preventLazyLoading(! $this->app->isProduction());
}Laravel documents this for a reason. If your team is serious about performance, accidental lazy loading should be treated like a design leak, not a harmless convenience.
And there is an uglier version of this problem that people miss. You can still trigger an N + 1 shape even after eager loading if the response layer keeps asking for relationships you did not plan for. That usually shows up in API resources, Blade loops, or admin screens where everyone assumes “it is only internal” right until internal traffic starts dominating the dashboard.
So the first decision is not “use Eloquent or not.” The first decision is whether your reads are explicit or emergent. Emergent reads are where slow Laravel apps are born.
The second bad decision is doing expensive work while the user waits
Laravel’s queue docs are refreshingly direct: long-running tasks should be moved out of the web request so the application can respond faster and give the user a better experience.
This sounds obvious. Teams still ignore it constantly.
Here is what gets left inside requests far too often:
- generating PDFs
- sending webhooks
- resizing images
- importing CSVs
- recalculating reports
- talking to third-party APIs with unpredictable latency
Then people stare at the response time graph and decide the language is the problem.
No. The app is slow because the request path is carrying work that should have become a job.
The naive version usually looks like this:
public function store(Request $request): RedirectResponse
{
$report = Report::create($request->validate([
'name' => ['required', 'string'],
]));
app(ReportExporter::class)->buildPdf($report);
app(PartnerWebhook::class)->send($report);
return redirect()->route('reports.show', $report);
}The user is not waiting for “PHP.” The user is waiting for your export pipeline and your outbound integration strategy.
The better version turns the request back into a request:
use App\Jobs\BuildReportPdf;
use App\Jobs\SendPartnerWebhook;
public function store(Request $request): RedirectResponse
{
$report = Report::create($request->validate([
'name' => ['required', 'string'],
]));
BuildReportPdf::dispatch($report->id);
SendPartnerWebhook::dispatch($report->id);
return redirect()->route('reports.show', $report);
}That is not just about queueing for the sake of queueing. It is about deciding what the user actually needs before the redirect returns.
If the answer is “the record should exist and the screen should move on,” then keep the request path small. If the answer is “the user must see the generated artifact immediately,” then own that trade-off and stop pretending the runtime is responsible for the wait.
Laravel gives you multiple ways to move work out of the hot path, including queued jobs and deferred execution after the response is sent. The engineering job is picking which one matches the product contract. Too many teams pick none and call the resulting slowness “framework overhead.”
It gets worse when this synchronous work sits behind a loop. One report generation is annoying. Fifty synchronous downstream calls inside an admin batch endpoint is how people end up saying things like “Laravel doesn’t scale” with a straight face.
The third bad decision is treating cache as optional
Laravel’s cache docs say some retrieval or processing tasks are CPU intensive or take several seconds, and caching lets subsequent requests retrieve them quickly. Again, this is not subtle. The framework is telling you where the repeated work goes to die.
What teams often do instead is postpone cache design until after the endpoint is already famous for being slow.
That approach creates two predictable outcomes:
- the app recalculates the same expensive result on every request
- the first cache patch is so rushed that nobody trusts invalidation afterward
There is a much calmer middle ground.
If a result is expensive and can be slightly old, cache it on purpose:
use Illuminate\Support\Facades\Cache;
$summary = Cache::remember(
"account-summary:{$account->id}",
now()->addMinutes(5),
fn () => app(AccountSummaryBuilder::class)->build($account)
);If the data can tolerate staleness during refresh windows, Laravel now gives you Cache::flexible for a stale-while-revalidate pattern. That matters because one of the worst performance habits in Laravel apps is making unlucky users pay the full recalculation cost every time a cached value expires.
There is also a smaller optimization that people skip because it sounds too mundane to matter: Laravel’s memo cache driver can keep resolved values in memory during a single request or job execution. That means you can stop hitting the same cache store repeatedly within one execution path. Not glamorous. Still useful.
What matters is the discipline behind it.
Caching is not an apology you make after the endpoint is already bad. It is a design decision about which results deserve reuse and how stale they are allowed to be before the user experience turns dishonest.
And yes, caching can be abused. Caching a fundamentally broken query shape just gives you a faster lie. But refusing to cache anything out of architectural purity is not seriousness. It is repeated work with better self-esteem.
The PHP-specific knobs come later than people want
This is the part where the title needs a little honesty.
PHP can absolutely contribute to a slow Laravel app. If you deploy without cached configuration, make the framework register routes from scratch on every production request, or ignore basic runtime tuning, you are choosing overhead. Laravel’s own docs say config:cache gives your application a speed boost, and the routing docs say route:cache drastically decreases the time it takes to register routes in production.
Those are good deployment decisions. Make them.
They are still not where I would look first when an endpoint is slow in a way users can actually feel. A config cache will not save you from a controller that fan-outs into fifty queries. A route cache will not rescue a request that insists on generating a PDF, calling two external APIs, and rebuilding a summary that should have been cached five minutes ago.
That is the real hierarchy:
- PHP and framework bootstrap overhead matter
- request-shape decisions usually matter more
- repeated database and integration work matters most
If you fix the three application decisions and the app is still dragging, then go lower in the stack. Check OPcache, cache routes and config, measure worker settings, look at container limits. Those are adult things to do on real systems.
Just do not start there because the runtime is easier to blame than the endpoint design.
PHP gets blamed because these decisions compound
Each of these mistakes is survivable on its own.
One lazy-loaded relationship might slip by. One synchronous export might be tolerable. One expensive uncached query might still clear within a sane response budget.
The real slowdown shows up when they stack:
- the controller loads a collection
- the resource layer triggers extra relationship queries
- the page recalculates summary widgets
- the request sends a webhook before returning
Now every layer is helping a little, which is how no single file looks guilty enough when you open it later.
This is also why “PHP is slow” feels emotionally satisfying. It gives the team one villain instead of three uncomfortable design conversations.
But once you profile a real Laravel app, the blame usually lands elsewhere:
- database query count
- time spent waiting on external services
- repeated work that should have been cached
PHP is the room where the argument happens. It is often not the person throwing the chairs.
What I would fix first in a real Laravel codebase
Not everything deserves a full performance project. Most teams need a triage order, not a manifesto.
This is the order I trust:
- Log slow queries and count how many queries each hot endpoint makes.
- Eliminate the obvious N + 1 paths with eager loading.
- Move export, notification and integration work off the request path.
- Cache the expensive reads users keep asking for.
- Only then decide whether you still have a PHP problem worth naming.
The important part is the sequence.
If you skip straight to runtime debates before fixing query shape and request scope, you are arguing about the engine while dragging the parking brake.
Laravel is usually honest about where the time went
That is one reason I still like working in it.
The docs already tell you the performance story if you read them without wishful thinking: eager loading fixes N + 1, queues move time-intensive work off the request, and cache exists because repeated expensive work is normal and avoidable. None of that sounds mysterious or novel, which makes it even stranger that teams still ship the opposite every week.
So no, your Laravel app is probably not slow because of PHP.
It is slow because three decisions were allowed to stay invisible for too long.


