Back to News & Insights
Web Development September 22, 2026 Β· 17 min read

Laravel Is Not As Heavy As You Think

Recently I came across another discussion about Laravel's memory usage and I can't say that it was...

Laravel Is Not As Heavy As You Think

Recently I came across another discussion about Laravel's memory usage and I can't say that it was exactly new to me, I've worked with Laravel and Lumen for years and have seen how differently they can behave in terms of memory, but it made me curious about what actually creates that difference. Is Laravel itself really that expensive, is Lumen significantly lighter because of the framework, or are we mostly looking at the effects of PHP configuration, OPcache, bootstrapping, and the way the application loads data? πŸ€·β€β™‚οΈ

So I decided to measure it properly and break down where the memory actually goes. The result was not what I expected.

A hello-world route in a fresh Laravel 13 app peaks approximately at 0.66MB of PHP memory. The framework's share of that is 316KB. That's everything that runs before my controller method does. Ten thousand Eloquent models loaded in the same app cost 17MB. I went in expecting the ratio to go the other way because the number everyone quotes for a Laravel request is "20MB, maybe 30" and the framework gets the blame for it.

And it turns out that this number is also accurate! It’s simply the value you get when OPcache is disabled, and OPcache is off in exactly the places people measure, which is php artisan tinker and the test suite and the dev container, so the number travels from there into the pm.maxchildren calculation and the memorylimit argument and the "Laravel is heavy" thread, and nobody checks it again. So this piece is the two numbers side by side, then a walk through the knobs everyone reaches for with a before and after for each (php artisan optimize, the Composer class map, deferred providers, OPcache itself), then the one thing that actually moved the number by a lot. Which was how the models got loaded.

The setup first so the numbers mean something. Laravel 13.31.0 on PHP 8.4.22, in the official php:8.4-fpm-alpine image on an Apple silicon Mac, with MySQL 8.4.11 in a second container, and every request going through real PHP-FPM, a static pool with a single worker, with cgi-fcgi sending the requests so nothing else touches the process. Every number below is the third request to a route, so the worker's warm and OPcache has compiled everything it's going to compile. And MB in this article means 1,048,576 bytes because that's what memorylimit counts in.

The measuring code is small. Two constants at the top of public/index.php before the autoloader loads:

And a controller method that takes a reading on entry and does the route's work and returns every counter I could think of as JSON:

The worker's resident memory comes from reading VmRSS out of /proc/self/status in the same method, which I left out of the listing. The items table has 100,000 rows. Its eight columns are the kind a product table has: a SKU, a name, a 120-character description, a price, a quantity, a flag and the two timestamps. It's boring on purpose, because I wanted the shape of a product table rather than a benchmark table.

Before any Laravel numbers I want the two functions straight. The gap between them confuses people and honestly I had to go read the allocator to be sure I understood it myself.

memorygetusage() is what the script has allocated right now through PHP's allocator. memorygetusage(true) is what that allocator has taken from the operating system whether the script is using it or not. The manual says the second one is "the value that memorylimit is enforced against" and adds that the amount the operating system has given the process is a different and typically much larger number. So there are three numbers, what you use, what the allocator holds and what the process holds, and this article is mostly about the first with a stop at each of the others.

The allocator is the Zend Memory Manager (Zend/zendalloc.c in php-src). It asks the OS for memory in chunks of exactly 2 1024 1024 bytes, the ZENDMMCHUNKSIZE constant in zendallocsizes.h, and it hands 4KB pages out of those chunks to the script. That's why memorygetusage(true) only ever returns multiples of 2,097,152. Never anything else. Every "real" figure in my results is one of those: 2,097,152 for the hello route and 20,971,520 for 10,000 models and 186,654,720 for 100,000. Anything bigger than a chunk minus one page is a "huge" allocation and gets its own mmap() call. So the very large numbers aren't rounded quite as coarsely.

Here's the part I didn't know. On the third request for 10,000 models the worker had just served the first two. memorygetusage(true) at the very top of index.php said 14,680,064. Seven chunks before a single line of Laravel ran. After two requests of 100,000 models the same line said 136,314,880. That's sixty-five chunks. The allocator doesn't hand chunks back at the end of a request. In zendmmshutdown it keeps a running average of how many chunks each request peaked at, holds that many in a cache for the next one, and resets the real counter to (cachedchunkscount + 1) ZENDMMCHUNKSIZE, so on a worker that has seen a heavy request the real number starts high and stays high for a while, even for a hello world, until enough small requests pull the average back down. That's not a leak, it's the allocator guessing that the next request will look like the last few.

Two practical consequences. memorylimit is enforced against the chunk count. A script whose memorygetusage() never reached 128MB can still die with "Allowed memory size of 134217728 bytes exhausted", because the chunks behind it got there first. And if you log memorygetpeakusage(true) from a long-lived worker as the cost of a request you're partly logging the previous request. For per-request work the non-real peak is the honest one. Since PHP 8.2 there's also memoryresetpeakusage() for the Octane and queue-worker case where the process never ends.

Now Laravel. Same hello route and same worker in two configurations, and that's the whole experiment. The first is what laravel new leaves you with when OPcache isn't on: no config cache and no route cache and the plain PSR-4 autoloader. The second is what a deploy script should leave you with: php artisan optimize plus an optimized class map plus OPcache on.

| Checkpoint | OPcache off, no caches | OPcache on, caches on | |---|---|---| | top of index.php | 385,200 | 339,640 | | after vendor/autoload.php | 2,007,888 | 360,608 | | after bootstrap/app.php | 3,285,760 | 418,152 | | controller method entry | 15,773,808 | 663,096 | | peak for the whole request | 17,827,888 | 691,216 | | files included | 460 | 400 | | classes declared | 444 | 410 | | wall time | 28.3ms | 0.8ms |

Bootstrap here means the difference between the top of index.php and the first line of the controller, and it's 15,388,608 bytes in the first column and 323,456 in the second. 14.7MB against 316KB for the same framework and the same 400-odd files and the same providers registered and booted. The 47x difference is where the compiled code lives.

This was one of those results where I reran the test a few times because the difference looked almost too large to be real. 😁😁😁

Without OPcache every one of those 460 files is read, tokenized, parsed and compiled on every request, and the result (the opcodes, the class tables, the constant arrays, the interned strings) is allocated in the request's own heap through the same allocator memorygetusage() watches, and that's the 15MB. Laravel isn't doing anything with it. It's the cost of holding the compiled form of Laravel in memory once per request per worker and throwing it away at the end.

Want to discuss this further?

Book a free strategy call with our team to see how these insights apply to your specific business goals.

Book a consultation