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.max_children calculation and the memory_limit 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 memory_limit counts in.
The measuring code is small. Two constants at the top of public/index.php before the autoloader loads:
public/index.php
define('LARAVEL_START', microtime(true));
define('MEM_INDEX_START', memory_get_usage());
define('MEM_INDEX_START_REAL', memory_get_usage(true));
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:
app/Http/Controllers/MeasureController.php
public function modelsGet(Request $request)
{
$n = (int) $request->query('n', 10000);
$entry = memory_get_usage();
$items = Item::query()->where('id', '<=', $n)->orderBy('id')->get();
return response()->json([
'index_start' => MEM_INDEX_START,
'route_entry' => $entry,
'after_hydrate' => memory_get_usage(),
'peak' => memory_get_peak_usage(),
'peak_real' => memory_get_peak_usage(true),
'count' => $items->count(),
]);
}
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.
memory_get_usage(true) Counts 2MB Chunks, And A Warm Worker Starts With Several
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.
memory_get_usage() is what the script has allocated right now through PHP's allocator. memory_get_usage(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 memory_limit 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/zend_alloc.c in php-src). It asks the OS for memory in chunks of exactly 2 * 1024 * 1024 bytes, the ZEND_MM_CHUNK_SIZE constant in zend_alloc_sizes.h, and it hands 4KB pages out of those chunks to the script. That's why memory_get_usage(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. memory_get_usage(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 zend_mm_shutdown 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 (cached_chunks_count + 1) * ZEND_MM_CHUNK_SIZE, 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. memory_limit is enforced against the chunk count. A script whose memory_get_usage() 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 memory_get_peak_usage(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 memory_reset_peak_usage() for the Octane and queue-worker case where the process never ends.
Without OPcache The Bootstrap Is 15MB. With It, 316KB
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 memory_get_usage() 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.
With OPcache on the compiled form lives in a shared memory segment. PHP-FPM's master process maps it once and every worker uses it. opcache_get_status() on the same hello request reported 412 cached scripts using 22,686,344 bytes of that segment out of the default 128MB (opcache.memory_consumption=128). So the 15MB didn't disappear, it became 21.6MB that's paid once per server instead of once per request, and it stopped showing up in memory_get_usage() entirely because OPcache's segment isn't the request heap.
An honesty note, since this is the part where a cheaper per-request number gets sold as a cheaper server. The FPM worker's resident memory for the hello route was 27.7MB with OPcache off and 30.7MB with it on. That's higher, not lower. The worker now has the shared segment's touched pages mapped in as well as its own heap. What OPcache buys isn't a smaller process. It's a process that doesn't grow by 15MB of compiled framework every time a request starts, and a request that takes 0.8ms instead of 28.
'php artisan optimize' And The Class Map Barely Move The Number
This is the section I expected to be the meat of the article, and it turned out to be the footnote. Every deploy guide says to run php artisan optimize (config:cache and event:cache and route:cache and view:cache in one command) and composer install --optimize-autoloader. Both are right. Neither's about memory.
Peak memory of the hello route in all eight combinations:
| Autoloader | artisan optimize | OPcache | Entry | Peak |
|---|---|---|---|---|
| PSR-4 | no | off | 15.04MB | 17.00MB |
| PSR-4 | yes | off | 14.33MB | 16.40MB |
| class map | yes | off | 15.53MB | 17.60MB |
| authoritative class map | yes | off | 15.53MB | 17.60MB |
| PSR-4 | no | on | 0.77MB | 0.80MB |
| class map | no | on | 0.77MB | 0.80MB |
| PSR-4 | yes | on | 0.63MB | 0.66MB |
| class map | yes | on | 0.63MB | 0.66MB |
php artisan optimize is worth 0.7MB per request without OPcache and 143KB with it, and that's the whole effect. You can see the mechanism in the file count: 460 included files without the caches and 400 with them. The config cache replaces reading .env plus every file in config/ with one require of a 20KB bootstrap/cache/config.php. The route cache replaces routes/web.php and all the registration calls with a single cached file. Those are real savings in time and syscalls (the docs describe config:cache as reducing "the number of trips the framework must make to the filesystem"), and they're tiny in memory, because reading thirty small PHP files and reading one bigger one produce roughly the same arrays in the end.
The class map is the more interesting row because it goes the wrong way. composer dump-autoload -o turns the PSR-4 rules into a flat array of 6,861 class-to-file entries (this app has 8,090 PHP files under vendor/) and that array has to exist in memory to be useful. Without OPcache it's built on every request, the "after autoload" checkpoint went from 2,007,888 bytes to 3,272,056, and the whole request got 1.2MB more expensive. With OPcache the array is a literal in a compiled file and OPcache stores literal arrays immutably in the shared segment and the per-request cost of the map is zero. The --classmap-authoritative flag, the one that stops Composer from checking the filesystem for classes missing from the map, changed nothing I could measure either way. Which is fine, because Composer's own docs sell the class map as a speed feature ("should always be enabled in production") and speed is what it delivers: fewer file_exists calls per class rather than fewer bytes.
So both knobs are worth turning and both are for CPU and disk rather than memory.
Deferred Providers Save Nothing Unless register() Does Work
Deferred providers come up in every Laravel performance thread so I tested them too. The framework already does this for itself: the compiled manifest in bootstrap/cache/services.php for this app lists 31 providers (30 from the framework plus AppServiceProvider), and 16 of them are eager and 15 are deferred. The question was what deferring my own provider would save.
Four providers added one at a time to bootstrap/providers.php, measured on the hello route with everything else in the production configuration:
-
A normal provider: three
singleton()bindings inregister(), nothing inboot(). The kind you write for a payment gateway or a search client. -
The same provider, deferred:
implements DeferrableProvider, withprovides()listing the three bindings. -
A heavy provider:
register()doesrequireon a 527KB PHP file that returns a 5,000-entry lookup array and binds it as an instance. The anti-pattern where "registering" a service means building it. -
The same heavy provider, deferred: the
requiremoves inside the singleton closure, so it runs only when something resolves the service.
Extra bytes at controller entry compared with no extra provider at all:
| Provider | OPcache off | OPcache on |
|---|---|---|
| normal, eager | +8,168 | +2,456 |
| normal, deferred | +384 | +0 |
| heavy, eager | +3,373,528 | +184 |
| heavy, deferred | +176 | +0 |
The normal provider costs eight kilobytes when it's loaded on every request and nothing when it's deferred. Eight kilobytes, for the whole provider, on every request. That's one file compiled and one object constructed. It's exactly what the docs say deferring saves ("it is not loaded from the filesystem on every request") and it's no more than that. If the app has forty providers like that then deferring all of them is worth about 300KB without OPcache and about 100KB with it. Rendering one small Blade page added about 200KB in the same runs.
The heavy provider is the row worth staring at. Without OPcache it costs 3.4MB per request because the whole 5,000-entry array gets built from source each time and deferring it takes that to zero. With OPcache it costs 184 bytes per request even when eager, because the array is a literal in a compiled file and OPcache keeps it as an immutable array in the shared segment, and I checked this twice since I didn't believe it: the segment's used_memory grew by 1.3MB when that provider was added and the request heap didn't move. require on a file that returns a constant array is close to free under OPcache. require on a file that computes an array or reads JSON or hits the network is not, and deferring is the fix for that whether OPcache is on or off.
So deferring's a real tool with a narrow target: providers whose register() actually does work. Marking a provider that only binds closures as deferred is correct and clean, and it won't show up on any graph.
Ten Thousand Models Cost 17MB. A Hundred Thousand Don't Fit
Everything so far was fractions of a megabyte in the production configuration. Here's what the route that loads data did on the same worker with the same OPcache and caches:
| Route (10,000 rows) | Entry | Peak | Peak, real | Time |
|---|---|---|---|---|
Item::query()->...->get() |
0.64MB | 18.44MB | 20.0MB | 40ms |
DB::table('items')->...->get() |
0.64MB | 14.15MB | 16.0MB | 9ms |
->chunk(1000, fn) |
0.64MB | 2.58MB | 6.0MB | 52ms |
->lazy(1000) |
0.64MB | 4.25MB | 6.0MB | 52ms |
->cursor() |
0.64MB | 3.17MB | 4.0MB | 122ms |
->cursor(), unbuffered query |
0.68MB | 0.88MB | 2.0MB | 119ms |
Ten thousand Eloquent models are 17,991,000 bytes on top of the entry point, which is 1,799 bytes per model for a row with eight columns. The same ten thousand rows through the query builder, as plain stdClass objects with no Eloquent around them, are 11,717,416 bytes or 1,172 per row, so the row itself (the strings and the numbers and the object that holds them) is about two thirds of the cost and Eloquent's $attributes array plus the $original copy it keeps for dirty checking plus the model object itself is the other third. Neither number is surprising once you know every PHP value carries a header and every array slot costs more than the value inside it. I think, the point is the ratio between them. One get() on ten thousand rows is fifty-five times the whole framework bootstrap and it's a line most codebases have somewhere, behind an export or a report or an admin page nobody paginated.
At 100,000 rows the get() route peaked at 183,961,728 bytes. That's 175MB, with the worker's resident memory at 208MB. Under the default memory_limit=128M it doesn't get that far:
PHP Fatal error: Allowed memory size of 134217728 bytes exhausted (tried to allocate 20480 bytes)
in /app/vendor/laravel/framework/src/Illuminate/Database/Connection.php on line 442
Then a second fatal 65,536 bytes later in ClassLoader.php, while Laravel tried to autoload the exception handler to report the first one, so the client got a 500 with an empty body, nothing reached laravel.log, and the only evidence of what happened was in the FPM error log.
The 50,000-row version fit at 88MB, and somewhere between those two numbers is the export that works in staging and dies on the first real customer. The fix isn't a bigger limit, it's the other four rows of the table, and they aren't equal.
chunk(1000) peaks at 2.58MB and stays there whether the table has 10,000 rows or 100,000 (2.65MB at 100k). It runs one LIMIT 1000 OFFSET n query per page, hands the page to the callback, and then (this is in BuildsQueries::chunk) calls unset($results) before fetching the next page, so there's one page in memory at a time, and the price is a hundred queries for a hundred thousand rows, 1,088ms in total against 407ms for the single get().
lazy(1000) peaks at 4.25MB. Higher than chunk, and I think that's because of how the generator's written. The loop is $results = $this->offset($offset)->limit($limit)->get() followed by a yield per row, so when page two is fetched page one is still sitting in $results until the assignment completes, which puts two pages at the peak instead of one, and it's still flat (4.32MB at 100k) and it's still the one I'd pick for a foreach, because 1.7MB isn't a reason to prefer chunk's callback style over a plain loop.
cursor() is the one people get wrong. I'd got it wrong too before I ran this. It runs a single query and yields one hydrated model at a time so it looks like it should be the smallest of the lot, and at 10,000 rows it's 3.17MB, and at 100,000 rows it's 24.28MB and climbing with the table. The Laravel docs say why in a sentence that's easy to skim past: it "will still eventually run out of memory" because of "PHP's PDO driver internally caching all raw query results in its buffer". A MySQL query is buffered by default, so mysqlnd pulls the entire result set into the PHP process before the loop sees the first row, and with mysqlnd that buffer counts against memory_limit. So cursor() saves you the hydrated models and nothing else. It doesn't save you the rows.
The last row of the table shows what happens when the buffer goes away. I set PDO::MYSQL_ATTR_USE_BUFFERED_QUERY to false on the connection before the same cursor() call and the 100,000-row loop peaked at 920,976 bytes. That's under a megabyte for the whole table in a single query. The manual's caveat is that an unbuffered result set owns the connection until it's fully read, so any query inside the loop on the same connection fails, and Laravel's own docs point you at lazy() instead for that reason, and I'd agree with them for anything that touches the database inside the loop. For a pure read-and-stream job an unbuffered cursor is the smallest thing PHP can do with a result set and honestly it's underused.
One more data point, because it changes the advice depending on the database. I repeated the cursor run against SQLite (a fresh Laravel install ships with it) and cursor() over 100,000 rows peaked at 847,088 bytes. The SQLite driver steps through rows as you ask for them with no client-side buffer. So the "cursor runs out of memory" warning's a MySQL fact rather than a PHP fact.
Okay, But My FPM Workers Show 35MB Each
That's the fair objection, and it's what makes people distrust the 0.66MB figure. ps on a production box shows php-fpm: pool www processes at 35MB and 60MB and 90MB and none of them are loading ten thousand models.
They're not lying, and neither is memory_get_peak_usage(), they're counting different things. Here's the same worker from the outside in three states with OPcache on:
| Worker state | RSS |
|---|---|
| serving the hello route (measured inside the request) | 30.7MB |
serving the 10,000-model get() (inside the request) |
52.1MB |
| idle, after the 10,000-model request has finished | 36.3MB |
The 30MB floor is the PHP binary and its extensions, plus the pages of the OPcache segment this worker has touched (shared with every other worker, but RSS counts them in each), plus the allocator's first chunk, and the 21MB on top of that during the model request is the heap from the previous section, and the 36MB after the request has finished is the allocator's chunk cache from two sections ago, the worker keeping some of the chunks it peaked at so that a worker that's served one heavy request stays bigger than one that hasn't until enough small requests pull the average back down. Multiply that by a pool where every worker eventually gets one heavy request and you've got the thing everyone observes on a dashboard, FPM memory that only goes up, with no leak anywhere in it.
Which is exactly why pm.max_children shouldn't be sized from memory_limit. The limit is a ceiling on one request's heap rather than a prediction of it, and dividing the box's RAM by 128MB gives you a pool less than half the size it could be for an app whose heaviest route peaks at 20MB. The manual describes pm.max_children as "the limit on the number of simultaneous requests that will be served". So the number to divide by is what one worker occupies while serving the heaviest route it will actually see. For this app on this box that's 52MB (the 10,000-model route measured from inside), so a 2GB budget for PHP gives about 39 workers, and if some route loads 100,000 models then it's 208MB per worker and 9 workers, and the right move there is to fix that route rather than buy the RAM, because the route that needs 208MB today is the route that'll need 400MB when the table doubles.
What I'd Actually Do
Measure the peak per route in production and keep the numbers. The cheapest version is a terminable middleware that logs the peak after the response has gone out:
app/Http/Middleware/LogPeakMemory.php
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
class LogPeakMemory
{
public function handle(Request $request, Closure $next)
{
if (function_exists('memory_reset_peak_usage')) {
memory_reset_peak_usage(); // PHP 8.2+, matters under Octane
}
return $next($request);
}
public function terminate(Request $request, $response): void
{
Log::info('peak', [
'route' => $request->route()?->uri(),
'peak_mb' => round(memory_get_peak_usage() / 1048576, 2),
]);
}
}
Append it globally in bootstrap/app.php and after a day you've got a histogram per route instead of a guess, and the routes at the top of it will be hydration, not bootstrap, so fix those first: paginate the admin page, lazy() the export, unbuffer the streaming job, and stop calling ->get() on a query with no LIMIT in it, because every one of those is worth more than all four bootstrap knobs combined, by a factor I measured at fifty-five.
Then turn the bootstrap knobs anyway, for time rather than memory. And make sure OPcache is on in every environment where someone is going to read a memory number, so the number they read is the real one.
Then size the pool from the log, not from memory_limit. That's it.
Octane changes the accounting but not the conclusion. It boots the application once per worker and keeps it in memory, so the 316KB is paid once and the per-request cost is only what the route itself allocates. But the worker never exits, and that's the catch. The model that leaks a reference or the static array that grows keeps growing, which is why Octane recycles a worker every 500 requests by default and why the memory_reset_peak_usage() line above stops being optional. If the Go version of this exercise is more your thing, I did the same measurement for goroutine stacks in I Spawned 1000000 Goroutines. Here's Where 13 GB of RAM Went. The shape of the answer was the same there: the number everyone quotes is true, it's just not the number that gets you paged.
The interesting part for me wasn't finding one magic Laravel setting that suddenly fixed memory usage. It was almost the opposite! Most of the framework-level optimizations changed far less than I expected, while one ordinary database call could outweigh the entire bootstrap dozens of times over. By the end of the tests, the framework overhead was almost the least interesting number. What the request loaded mattered far more.
Laravel costs 316KB a request. Your rows cost whatever you ask for.
Thanks for reading! English isn't my first language, so I use AI to polish the grammar. Everything else here - the ideas, the code, the opinions - is mine.
Enjoyed this one? Let's stay in touch β I'm on LinkedIn, always happy to chat, swap ideas, or just say hi. π

Top comments (0)