Skip to content
September 23, 2026•8 min read•By Dzaki Amri Zaidaan

Laravel Is Not As Heavy As You Think: A Production Memory & Latency Analysis

A production-grade analysis of Laravel's real memory footprint and request latency, benchmarking the framework against raw PHP and Symfony, and showing how OPcache, JIT, and Octane change the cost model entirely.

#Backend#Architecture
Colorful software or web code on a computer monitor

Laravel Is Not As Heavy As You Think: A Production Memory & Latency Analysis

Recently I came across another discussion about Laravel's memory usage and I can't say that it was grounded in measurement. The recurring claim is that Laravel is "heavy" — that it eats hundreds of megabytes per worker, that it adds 50–100ms of overhead per request, and that it is unsuitable for anything beyond a CRUD app. These claims usually trace back to a single memory_get_peak_usage() call inside a debug route, or a php -r benchmark that boots the framework without OPcache, without JIT, and without a real HTTP kernel.

This article is a measurement-first rebuttal. We will look at what actually consumes memory in a Laravel request, how the framework's boot cost amortizes across OPcache and long-lived workers, and where the real trade-offs live. The goal is not to defend Laravel — it is to give you the numbers and the mental model to make your own call.

The Problem & Industry Shift

The "Laravel is heavy" narrative is a legacy of PHP's shared-nothing execution model. In classic PHP-FPM, every request re-executes the entire bootstrap: autoloader registration, service container construction, config loading, route registration, middleware pipeline assembly. That work is real, and it is repeated on every single request. A naive measurement of that bootstrap in isolation looks alarming.

But the industry has shifted in three ways that invalidate the old benchmark:

  1. OPcache is now default-on in production. Since PHP 5.5, and aggressively tuned in PHP 8.x, compiled bytecode is cached in shared memory. The autoloader and framework files are not re-parsed per request [1].
  2. PHP 8.x JIT and typed properties reduce the interpreter overhead that dominated PHP 7.x benchmarks. The JIT is not a silver bullet for I/O-bound web apps, but it materially changes CPU-bound bootstrap cost [2].
  3. Long-lived workers (Laravel Octane, RoadRunner, FrankenPHP) eliminate the per-request bootstrap entirely. The container is booted once and reused across thousands of requests [3].

The old benchmark measured a code path that no well-configured production deployment actually runs. That is the core of the misunderstanding.

Architecture & Core Mechanics

To reason about cost, you need to separate the request lifecycle into phases and understand which phases are amortizable.

Classic PHP-FPM (shared-nothing)
┌─────────────────────────────────────────────────────────────┐
│ Request N                                                   │
│  ┌──────────────┐  ┌──────────────┐  ┌──────────────────┐   │
│  │ Autoload +   │→ │ Container +  │→ │ Route + Handler  │   │
│  │ Bootstrap    │  │ Providers    │  │ + Response       │   │
│  └──────────────┘  └──────────────┘  └──────────────────┘   │
│   ^ repeated every request (OPcache mitigates parse cost)   │
└─────────────────────────────────────────────────────────────┘

Laravel Octane (long-lived worker)
┌─────────────────────────────────────────────────────────────┐
│ Worker boot (once)                                          │
│  ┌──────────────┐  ┌──────────────┐                         │
│  │ Autoload +   │→ │ Container +  │  (kept in memory)       │
│  │ Bootstrap    │  │ Providers    │                         │
│  └──────────────┘  └──────────────┘                         │
├─────────────────────────────────────────────────────────────┤
│ Request N                                                   │
│  ┌──────────────────┐                                       │
│  │ Route + Handler  │  (only this runs per request)         │
│  │ + Response       │                                       │
│  └──────────────────┘                                       │
└─────────────────────────────────────────────────────────────┘

The key insight: the expensive part of Laravel is the boot, and the boot is exactly the part that long-lived workers eliminate. In Octane, the per-request cost converges toward the cost of your actual handler logic plus routing and middleware dispatch.

What actually consumes memory

Per-worker memory in a Laravel app is dominated by:

  • Loaded class definitions (OPcache shared memory, not per-process RSS in the same way).
  • The service container and its resolved singletons — config repository, router, event dispatcher, DB connection pool.
  • Application state that leaks across requests in long-lived workers — this is the real risk with Octane, not raw memory.

A typical Laravel 11 app under PHP 8.3 with OPcache enabled reports a peak of roughly 10–25 MB per FPM worker for a simple JSON endpoint. The "hundreds of megabytes" figure usually comes from measuring a worker that has loaded a large set of Composer packages, or from a misconfigured opcache.memory_consumption that forces recompilation.

Production Code Example

The following is a runnable benchmark harness that measures the three phases separately. It is written as a Laravel console command so you can drop it into app/Console/Commands and run it against your real application.

<?php
// app/Console/Commands/BenchmarkBoot.php

namespace App\Console\Commands;

use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Route;

class BenchmarkBoot extends Command
{
    protected $signature = 'bench:boot {--iterations=1000}';
    protected $description = 'Measure bootstrap, routing, and handler cost separately';

    public function handle(): int
    {
        $iterations = (int) $this->option('iterations');

        // Phase 1: measure the cost of resolving a container binding.
        // This is the closest proxy for "framework overhead" per request.
        $start = hrtime(true);
        for ($i = 0; $i < $iterations; $i++) {
            app('router'); // resolves the cached singleton
        }
        $containerNs = (hrtime(true) - $start) / $iterations;

        // Phase 2: measure route matching against a real route.
        // Route::getRoutes() is the compiled route collection.
        $routes = Route::getRoutes();
        $start = hrtime(true);
        for ($i = 0; $i < $iterations; $i++) {
            $routes->match(
                \Illuminate\Http\Request::create('/api/health', 'GET')
            );
        }
        $routeNs = (hrtime(true) - $start) / $iterations;

        // Phase 3: measure a real DB round-trip. This is usually the
        // dominant cost and has nothing to do with the framework.
        $start = hrtime(true);
        for ($i = 0; $i < $iterations; $i++) {
            DB::select('SELECT 1');
        }
        $dbNs = (hrtime(true) - $start) / $iterations;

        $this->table(
            ['Phase', 'Avg (µs)', 'Avg (ms)'],
            [
                ['Container resolve', $containerNs / 1000, $containerNs / 1e6],
                ['Route match',       $routeNs / 1000,     $routeNs / 1e6],
                ['DB SELECT 1',       $dbNs / 1000,        $dbNs / 1e6],
            ]
        );

        $this->line(sprintf(
            'Peak memory: %.2f MB',
            memory_get_peak_usage(true) / 1024 / 1024
        ));

        return self::SUCCESS;
    }
}

Run it with php artisan bench:boot --iterations=10000. On a typical container (2 vCPU, PHP 8.3, OPcache enabled), you will see container resolution in the low single-digit microseconds, route matching in the tens of microseconds, and the DB round-trip dominating at hundreds of microseconds to low milliseconds. The framework is not your bottleneck; your I/O is.

Performance, Cost & Trade-offs

Here is the honest trade-off table, based on the phase decomposition above:

ConfigurationBoot cost per requestPeak RSS per workerNotes
PHP-FPM, no OPcache15–40 ms20–40 MBNever do this in production
PHP-FPM, OPcache on3–8 ms10–25 MBThe realistic baseline
PHP-FPM, OPcache + JIT2–6 ms12–28 MBJIT helps CPU-bound paths
Octane (Swoole/FrankenPHP)~0 ms (amortized)30–80 MBMemory grows with state; watch leaks

The Octane row deserves scrutiny. You trade per-request boot cost for persistent memory that must be managed. Any static state, singleton holding request data, or unclosed resource will accumulate. This is the actual engineering cost of Octane, and it is a discipline problem, not a framework problem. Laravel's docs are explicit about this: you must reset state between requests, and Octane provides hooks for it [3].

On the JIT: do not expect it to rescue an I/O-bound app. The JIT optimizes CPU-bound loops. For a typical CRUD API, the win is marginal. For a data-processing endpoint, it can be significant. Benchmark your own workload [2].

On memory specifically: the number that matters is peak RSS per worker × worker count, not the framework's theoretical footprint. A 25 MB worker × 20 workers = 500 MB. That is the real budget. Reducing it means reducing loaded packages, not blaming the framework.

Actionable Checklist / Summary

When you evaluate Laravel's weight for your own workload, do this:

  • Enable OPcache in production. Set opcache.enable=1, opcache.memory_consumption=256, opcache.validate_timestamps=0 for immutable deploys. This alone eliminates most of the "heavy" perception [1].
  • Measure phases separately. Use the command above. Do not attribute DB latency to the framework.
  • Audit your Composer dependencies. composer why and composer show --tree. The framework is rarely the largest contributor to loaded classes.
  • Consider Octane only if boot cost is measurable. For most apps, PHP-FPM with OPcache is simpler and safer. Octane is a tool for high-throughput, latency-sensitive services.
  • If you adopt Octane, treat state as a first-class concern. Audit singletons, static properties, and request-scoped data. Write tests that run multiple requests in one worker.
  • Benchmark with your real handler, not a hello-world route. The framework overhead is a constant; your handler is the variable.

The conclusion is not that Laravel is free. It is that the framework's cost is a small, predictable, and largely amortizable constant, while the costs that dominate real applications — database round-trips, external HTTP calls, serialization — are independent of the framework. Measure those first.

References