When a PHP program requests an allocation whose size the engine can compute before doing any work, the request ends in an uncatchable fatal error. Examples include: str_repeat('x', PHP_INT_MAX), array_fill(0, 2 ** 30, 0), new SplFixedArray(2 ** 30), and $str[2 ** 40] = 'x'. Today each of these results in a fatal error with either the memory manager’s “Allowed memory size exhausted” message or, when the size arithmetic overflows, “Possible integer overflow in memory allocation.” This is unnecessarily severe. The requested size is a simple arithmetic function of the arguments, and comparing it against the configured memory_limit requires no allocation. There is no reason a single bad length argument must terminate the whole request instead of throwing an error the program can catch, log, and recover from.
This RFC proposes a new API and throwable error that allows the PHP engine and extensions to pre-calculate an allocation and throw a MemoryError when it determines the allocation will consume the remaining available memory within the configured memory_limit.
MemoryError is thrown when PHP determines a requested allocation cannot fit within the configured memory_limit.
class MemoryError extends Error { }
The following ZEND_API functions allow checking the size of an allocation ahead of time and throwing a MemoryError if the allocation will exceed the configured memory_limit:
/* Zend/zend_alloc.h */ ZEND_API size_t zend_memory_limit(void); ZEND_API bool zend_memory_limit_is_unlimited(void); ZEND_API bool zend_alloc_size_exceeds_memory( size_t nmemb, size_t size, size_t offset, const char *kind); /* Zend/zend_string.h */ ZEND_API bool zend_string_alloc_size_exceeds_memory( size_t nmemb, size_t size, size_t offset); /* Zend/zend_hash.h */ ZEND_API bool zend_array_alloc_size_exceeds_memory(size_t nelems, bool packed);
zend_alloc_size_exceeds_memory() provides the core functionality. It computes nmemb * size + offset with overflow detection. If the computation overflows or the result of the allocation would exceed the memory still available under memory_limit (the limit minus current usage), then it throws a MemoryError and returns true. Otherwise, it returns false without throwing. If memory_limit is -1 (unlimited), it also returns false without throwing.
The string helper is a thin wrapper with kind "string". The array helper converts an element count into a lower-bound byte estimate of the hash table’s element storage (sizeof(zval) per element for packed arrays, bucket + hash slot otherwise) with kind "array"; underestimating is safe because a miss simply leaves the allocator’s existing behavior (i.e., OOM fatal error) in place.
This RFC implements the guard in the engine, as well as a set of built-in functions whose result or buffer size is a direct function of an integer argument (or of a size the function can compute up front, before doing any work). Every site follows the same pattern: the guard sits after the function’s existing argument validation and immediately before the allocation.
| Site | Current behavior | New behavior |
|---|---|---|
$str[$offset] = $c (extending the string) | uncatchable fatal | MemoryError, string unchanged |
str_repeat($s, $times) | uncatchable fatal | MemoryError |
str_pad($s, $length) | uncatchable fatal | MemoryError |
mb_str_pad($s, $length) | uncatchable fatal (catchable Error: String size overflow on a rare byte-count overflow path) | MemoryError |
number_format($n, $decimals) | uncatchable fatal | MemoryError |
chunk_split($s, $length, $end) | uncatchable fatal | MemoryError |
pack($format, ...) (computed output size) | uncatchable fatal | MemoryError |
sprintf()/printf() (field width, e.g., %2000000000d) | uncatchable fatal | MemoryError |
When the element count is known before any element is written, we can compute the exact size of the array’s storage and throw a MemoryError if the allocation would exceed the memory_limit.
| Site | Current behavior | New behavior |
|---|---|---|
array_fill($start, $count, $value) | uncatchable fatal | MemoryError |
array_pad($array, $length, $value) | uncatchable fatal | MemoryError |
range($start, $end, $step) | uncatchable fatal | MemoryError |
str_split($string, $length) | uncatchable fatal | MemoryError |
mb_str_split($string, $length) | uncatchable fatal | MemoryError |
new SplFixedArray($size), SplFixedArray::setSize() | uncatchable fatal | MemoryError, array unchanged |
When the length argument is an upper bound the function allocates in full before reading or generating, we can compute the exact size of the buffer and throw a MemoryError if the allocation would exceed the memory_limit.
| Site | Current behavior | New behavior |
|---|---|---|
random_bytes($length) | uncatchable fatal | MemoryError |
openssl_random_pseudo_bytes($length) | uncatchable fatal | MemoryError |
sodium_crypto_stream($length, ...), sodium_crypto_stream_xchacha20($length, ...), sodium_crypto_pwhash($length, ...) | uncatchable fatal | MemoryError |
fread($h, $length) | uncatchable fatal | MemoryError |
fgets($h, $length) | uncatchable fatal | MemoryError |
stream_socket_recvfrom($s, $length) | uncatchable fatal | MemoryError |
socket_read($s, $length) | uncatchable fatal | MemoryError |
socket_recv($s, &$buf, $length, $flags) | uncatchable fatal | MemoryError |
socket_recvfrom($s, &$buf, $length, ...) | uncatchable fatal | MemoryError |
msg_receive($q, ..., $maxSize, ...) | uncatchable fatal | MemoryError |
Random\Randomizer::getBytes($length), getBytesFromString($s, $length) | uncatchable fatal | MemoryError |
fgetcsv($h, $length, ...) | uncatchable fatal | MemoryError |
shmop_read($id, $offset, $size) | uncatchable fatal | MemoryError |
pg_lo_read($lo, $length) | uncatchable fatal | MemoryError |
gmp_strval($num, $base), GMP (string) cast, gmp_export($num, ...) | uncatchable fatal | MemoryError |
Note on shmop_read(): A shared-memory segment can legitimately be larger than memory_limit, so reading a whole large segment into a PHP string is a predictable failure; the guard makes it catchable and partial reads keep working.
Note on GMP: libgmp uses its own allocator, entirely outside memory_limit. For example, gmp_pow(2, 2000000000) succeeds under a 64 MB limit, producing a ~250 MB number. That pre-existing behavior is out of scope here. This RFC guards the rendering of such a number into a PHP string, whose exact size mpz_sizeinbase() provides up front. The arithmetic functions themselves have nothing to check against.
For the read buffers, any request the guard rejects is one that already fails with a fatal error today, so it either worked before and still works, or it was already fatal and now throws.
printf-family precision is deliberately not guarded. It is capped at 53 digits for floats and acts as a truncation limit for strings, so it never attempts a large allocation.
Built-in functions (where kind is string, array, or SplFixedArray as appropriate):
The resulting <kind> is too large to fit in the configured memory limit
String offset assignment:
String offset assignment produces a string too large to fit in the configured memory limit
ini_set('memory_limit', '64M'); try { $s = str_repeat('x', PHP_INT_MAX); } catch (MemoryError $e) { // The resulting string is too large to fit in the configured memory limit echo $e->getMessage(); } $s = 'abc'; try { $s[2 ** 40] = 'Z'; } catch (MemoryError $e) { // String offset assignment produces a string too large to fit in the configured memory limit echo $e->getMessage(); // $s is still 'abc' }
The check is against available memory, i.e., memory_limit minus current usage.
“Current usage” means used bytes (zend_memory_usage(false)), rather than the allocator’s reserved chunk size. Reserved-but-free chunk space can satisfy an allocation, so counting it as part of current usage would reject requests that succeed today. For example, bug78902.phpt (a read loop under a 2 MB limit whose per-chunk strings are freed each iteration) demonstrates this condition and passes unchanged under this rule. The trade-off is that the check can miss an allocation that fails at the chunk level (i.e., fragmentation); such cases result in an OOM fatal error, so the behavior is unchanged.
When memory_limit = -1 (unlimited), the engine never throws. The engine proceeds with the allocation. If the operating system cannot satisfy it, the existing OOM fatal error behavior applies. The check is a comparison against the configured limit, not a guess about system memory.
Failed operations have no effect (i.e., state is unchanged). For example, the string offset assignment leaves the target string unchanged. The throw occurs before allocating or copying anything.
Genuine out-of-memory errors that occur when allocation fails during execution remain uncatchable fatal errors. Making these catchable would require the engine to allocate (e.g., an exception object, stack frames for finally blocks, destructor execution, etc.) at the precise moment no memory is available. This RFC only adds guards at sites where the size is computable before allocation.
The check is placed at the guarded call sites, not inside the allocator. emalloc() and zend_string_alloc() are untouched, so allocation elsewhere in the engine is unchanged. Each guard costs one overflow-checked multiply-add (zend_safe_address()) plus two or three reads of the heap globals and comparisons. The throw paths are marked UNEXPECTED. The string-offset guard is only on the extend-if-needed branch of zend_assign_to_string_offset(), so ordinary in-bounds $s[$i] = 'x' writes never execute it.
The following benchmarks measure the impact of executing the guards on release builds (i.e., -O2 and no --enable-debug). “Without guards” is a build of php/php-src at 9073875eb9, the merge-base where the memory-error branch diverges from master; “with guards” is the branch tip. Every guarded function is measured on its success path with small inputs, so the numbers show the per-call cost of the guard.
| Case | Without guards | With guards | Delta |
|---|---|---|---|
| String builders | |||
str_repeat() (10 B result) | 30 ns | 32 ns | +8.2% |
str_repeat() (64 KB result) | 677 ns | 698 ns | +3.0% |
str_pad() (16 B) | 36 ns | 37 ns | +1.3% |
mb_str_pad() (16 B) | 72 ns | 71 ns | −1.8% |
number_format($x, 2) | 213 ns | 214 ns | +0.4% |
chunk_split() (16 B / 4) | 43 ns | 45 ns | +5.7% |
pack('N4', …) | 44 ns | 46 ns | +4.3% |
sprintf('%10d', …) | 81 ns | 82 ns | +2.1% |
| String-offset write, extending (×4) | 60 ns | 68 ns | +12.2% |
| String-offset write, in-bounds (×4) | 50 ns | 50 ns | +0.1% |
| Array / data-structure builders | |||
array_fill() (10 elements) | 45 ns | 45 ns | −0.3% |
array_fill() (1000 elements) | 732 ns | 730 ns | −0.3% |
array_pad() (2 → 8) | 47 ns | 47 ns | −1.1% |
range(1, 10) | 48 ns | 49 ns | +1.3% |
str_split() (16 B / 4) | 57 ns | 57 ns | +0.5% |
mb_str_split() (16 B / 4) | 88 ns | 89 ns | +0.4% |
new SplFixedArray(10) | 80 ns | 79 ns | −0.8% |
SplFixedArray::setSize() grow (4 → 16) | 122 ns | 122 ns | −0.4% |
| Argument-sized read/output buffers | |||
random_bytes(16) | 313 ns | 315 ns | +0.5% |
Randomizer::getBytes(16) | 610 ns | 611 ns | +0.2% |
Randomizer::getBytesFromString() (16 B) | 972 ns | 972 ns | −0.1% |
openssl_random_pseudo_bytes(16) | 492 ns | 488 ns | −0.8% |
sodium_crypto_stream(32) | 253 ns | 253 ns | +0.1% |
sodium_crypto_stream_xchacha20(32) | 249 ns | 251 ns | +0.8% |
fread($h, 16) (php://memory) | 58 ns | 57 ns | −1.1% |
fgets($h, 16) (php://memory) | 76 ns | 75 ns | −0.9% |
fgetcsv($h, 64) | 354 ns | 356 ns | +0.8% |
stream_socket_recvfrom(16) | 579 ns | 585 ns | +1.2% |
socket_read(16) | 571 ns | 575 ns | +0.7% |
socket_recv(16) | 579 ns | 583 ns | +0.7% |
socket_recvfrom(16) | 616 ns | 623 ns | +1.1% |
msg_receive() (empty, NOWAIT) | 272 ns | 277 ns | +1.6% |
shmop_read(16) | 47 ns | 47 ns | +0.7% |
pg_lo_read(16) (live server) | 90.6 µs | 90.8 µs | +0.1% |
| GMP rendering | |||
gmp_strval() (5 digits) | 50 ns | 51 ns | +0.4% |
(string) GMP cast (5 digits) | 52 ns | 53 ns | +0.6% |
gmp_export() (5 digits) | 40 ns | 40 ns | +0.3% |
Methodology. The suite is a PHPBench project; each subject runs 200,000–3,000,000 revolutions per iteration for 10 iterations, with a 5% retry threshold, and the table reports the mode. Delta percentages are PHPBench’s mode comparison against the stored baseline run. The two builds are compiled in Docker images from the same base image and identical configure flags, and ran in aarch64 Linux containers on Apple silicon (M1 Max), on an otherwise idle machine, with a PostgreSQL 17 container backing pg_lo_read(). The benchmark project is available at https://github.com/ramsey/memory-error-bench.
MemoryError is declared in the global namespace. A userland class with that exact name will now cause a fatal “Cannot redeclare class MemoryError” error. A survey of public code (below) found no global-namespace declaration of the name.mb_str_pad() currently throws Error with the message “String size overflow” when its internal byte-count arithmetic overflows (most oversized requests instead die with the memory-exhaustion fatal before reaching that check). That throw site now uses MemoryError with the standard message above. Code catching Error (or Throwable) is unaffected; only code matching the message changes behavior.memory_limit and then calls a guarded function gets a MemoryError even for a small amount of data; the request could not have succeeded, but previously it reached the allocator and died with an uncatchable fatal error. (Since the check counts used bytes, memory that was allocated and freed again does not trigger this; only genuinely live data does.).MemoryError. Two core tests asserting that fatal error (bug67247.phpt for SplFixedArray::setSize() and array_fill_error2.phpt) were re-written to expect the exception; the SplFixedArray test now additionally verifies the array is left unchanged.Surveyed 2026-07-10
A collision requires a global namespace symbol of the name MemoryError. Because PHP classes, interfaces, traits, and enums share one symbol table and class names are case-insensitive, the survey covered all four declaration kinds plus class_alias(). Several search engines were used, since each covers code the other misses:
(class|interface|trait|enum)\s+MemoryError\bclass_alias\s*\(.*MemoryErrorLiquid\Exceptions\MemoryError, not in the global namespaceFindings:
MemoryError.Liquid\Exceptions\MemoryError.PHP 8.6.
zend_string_alloc_size_exceeds_memory() where they can predict allocation sizes.zend_assign_to_string_offset(), a slow-path helper shared by the interpreter and JIT; no specialized handlers change.
The functions included in this RFC are those where the size of the allocation is known beforehand and where the guard is a simple insertion. Other functions where guards may be included are reserved for future scope. Converting an uncatchable fatal error to a MemoryError at additional sites after this RFC should be treated as a bug-fix improvement not requiring an RFC.
Simple yes/no vote requiring a 2/3 majority:
TBD
MemoryError: https://docs.python.org/3/library/exceptions.html#MemoryErrorError subclasses): https://wiki.php.net/rfc/throwable-interface