PHP

Allowed memory size exhausted: finding what actually ate the RAM

Raising memory_limit makes the message go away for about a week. Here is how to read the error properly, find the code that actually allocated the memory, and work out whether more RAM is a fix or a postponement.

PHP Fatal error: Allowed memory size of 134217728 bytes exhausted (tried to allocate 20480 bytes) is one of the most misread errors in PHP. It looks like a capacity problem. Nine times out of ten it is a code problem that capacity was hiding.

What the message actually tells you

There are two numbers in that line and they mean very different things.

  • 134217728 bytes is your memory_limit, in bytes. That one is 128M. This is a per-process ceiling, not a server total, and PHP enforces it itself.
  • tried to allocate 20480 bytes is the request that failed, which is the single most useful diagnostic in the message and the one everyone skips.

If the second number is small, say under a megabyte, nothing dramatic happened at the end. The process was already sitting at 127.9M and a routine 20KB allocation tipped it over. The culprit is upstream, in whatever accumulated those 127.9 megabytes, and the failing line is just the unlucky one.

If the second number is large, 50M or 200M or more, then one allocation really did try to take a huge bite, and the failing line is genuinely where to look. That is usually an image, a file read into a string, or a query result hydrated all at once.

Why the named file is usually innocent

PHP reports the file and line where the ceiling was hit. With a slow accumulation, that is wherever the next allocation happened to land: a template partial, a logger, str_replace inside a helper. Developers then "optimise" a perfectly reasonable function, ship it, and hit the same error two days later from a different file.

The question to ask is never "what is wrong with this line". It is "what was already holding 128 megabytes by the time this line ran".

The four things that really cause it

1. A result set loaded whole

Fetching every row and hydrating each one into an object is the classic. Ten thousand rows of twenty columns is nothing in the database and hundreds of megabytes in PHP once each row becomes a model instance with its own relations. In WordPress this is get_posts() or WP_Query with 'posts_per_page' => -1, which is a loaded gun pointed at a site that grows.

2. Image processing

GD and Imagick decompress to raw bitmaps. A 2MB JPEG at 6000x4000 pixels is roughly 6000 x 4000 x 4 bytes, about 96MB, in memory, before you do anything to it. Two of those in one request and 128M is gone. This is why the error so often appears on upload or on a thumbnail regeneration run, and never during normal browsing.

3. Accumulation in a loop

An array that is appended to inside a loop that iterates over everything, a string concatenated in a loop, a log buffer that is never flushed. Memory grows linearly with data volume, so it works in development with 50 records and dies in production at 50,000. These are the ones that appear months after deploy with no code change at all.

4. Recursion or a reference cycle

This one is rarer, but when it does happen the growth is fast rather than linear. The usual shapes are an object graph that references itself, a recursive function whose termination condition is wrong for certain inputs, or an autoloader loop. Look here when the error is intermittent and tied to one specific record.

Finding the allocation, not guessing at it

Before changing any configuration, get a measurement. memory_get_peak_usage(true) returns the real high-water mark in bytes, including PHP's own allocator overhead. Drop it at a few checkpoints and the growth shows up immediately:

error_log(sprintf(
    '[mem] %s: %.1f MB',
    'after-query',
    memory_get_peak_usage(true) / 1048576
));

Three or four of those through a request narrows the problem to one block of code in a single reload. It is cruder than a profiler and it works everywhere, including on shared hosting where you cannot install anything. If you can install something, Xdebug's memory profile or Blackfire will show you the exact call tree.

Two details that save time. Pass true to get the allocated size rather than the used size, because the allocated figure is what PHP compares against the limit. And check the peak, not the current value, since memory freed before the error still counted on the way up.

When raising the limit is correct

Sometimes it genuinely is. The honest test is whether the memory is bounded by the work or bounded by your data.

  • Raise it when the peak is stable and the job legitimately needs that much: image processing, a PDF render, a one-off import. The number will not grow next month because the work is the same shape.
  • Fix the code when the peak tracks the number of rows, files, or users. Raising the limit here buys you however long it takes the data to grow, which on a healthy site is weeks.

The practical compromise most teams land on: raise the limit so the site stops erroring tonight, open a ticket for the real fix, and put a note on the config line saying which job needed it. An undocumented memory_limit = 512M is a thing nobody will ever dare lower again.

WordPress, Laravel and CLI specifics

WordPress

WordPress has its own layer on top of php.ini. In wp-config.php, above the "stop editing" line:

define('WP_MEMORY_LIMIT', '256M');
define('WP_MAX_MEMORY_LIMIT', '512M');

WP_MEMORY_LIMIT applies to the front end. WP_MAX_MEMORY_LIMIT applies to admin screens and cron, which are where the heavy jobs live. Neither can exceed what the host allows, so if the values appear to do nothing, your host has a hard cap and you need to raise it there or ask them to.

Laravel and Symfony

Framework bootstrapping is not usually the problem; collections are. Model::all() loads everything. chunk() and cursor() exist precisely for this, and cursor() uses lazy collections so memory stays flat regardless of row count. For queued jobs, remember the worker is a long-lived process: memory that leaks per job accumulates across jobs until the worker is recycled.

The CLI has its own limit

This one catches people constantly. The CLI SAPI reads a different php.ini than FPM, and on many distributions the CLI limit is already -1. So a script runs fine by hand and dies under cron or through the web. Check with php -i | grep memory_limit for the CLI value and a phpinfo() page for the web value, and expect them to differ. To override for one run:

php -d memory_limit=512M artisan import:run

Stopping the 3am repeat

Memory exhaustion is one of the most predictable production failures there is, because it almost always follows data growth rather than a deploy. That also makes it one of the easiest to catch early, if anything is actually watching the log.

Two things worth putting in place regardless of what tooling you use. First, make fatals visible: a register_shutdown_function that checks error_get_last() for E_ERROR and reports it somewhere you will see, because a fatal never reaches your exception handler. Second, log the peak memory on slow or heavy endpoints, so the climb is visible as a trend rather than as an outage.

The failure mode that costs real money is not the error itself. It is the eight hours between the site breaking and someone noticing, and that gap is what Patcherly exists to close. The error is caught as it happens, the fix is drafted and waiting, and you decide from your phone whether it applies.

Found something wrong in here? Tell me.

Checking API...
Checking AI Gateway...
Check status
⚠️ Setup Required