6 min read

PHP 8.5.10 and 8.4.25: The Stack Overflow Hardening Wave Hits Core Array and DOM Functions

PHP 8.4.25 and 8.5.10 RC add stack limit checks to array_walk_recursive, compact, array comparison, and DOM normalize. Segfaults become catchable Errors.

Featured image for "PHP 8.5.10 and 8.4.25: The Stack Overflow Hardening Wave Hits Core Array and DOM Functions"

If you have ever watched a PHP-FPM worker vanish without a log entry, no exception, no fatal error, just a WARNING: child exited on signal 11 (SIGSEGV) in the FPM log, there is a decent chance a deeply nested array was involved. PHP 8.3 shipped a mechanism to stop exactly that class of crash, but the coverage inside core functions has been patchy ever since. The release candidates the PHP project tagged on August 11, 2026 close a large batch of those gaps in one go.

PHP 8.4.25 RC1 and PHP 8.5.10 RC1 both landed with a set of commits that do the same unglamorous thing in about a dozen places: check whether the C stack is about to run out, and throw an Error instead of letting the process die. The functions affected are ones nearly every PHP application touches.

The thing that has been broken since forever

Start with a shape that shows up constantly in real applications, a nested tree built from user input:

<?php
// Build a legal but very deep array, the kind a hostile
// client can hand you in a single JSON body.
$deep = [];
$node = &$deep;
for ($i = 0; $i < 100_000; $i++) {
    $node['child'] = [];
    $node = &$node['child'];
}
unset($node);

array_walk_recursive($deep, static fn ($v) => $v);

Before these patches, on PHP 8.4.24 or 8.5.9, that second-to-last line does not throw. It does not warn. The process segfaults. php_array_walk() in ext/standard/array.c recursed once per nesting level with no stack check at all, so it just kept pushing C frames until the operating system said no.

The existing guard in that code, GC_IS_RECURSIVE, only catches self-referential arrays, the classic $a['self'] = &$a; cycle. Plain depth was never bounded. Same story in php_array_replace_recursive() and php_compact_var(), both of which recurse one level at a time with nothing watching the stack.

That distinction is the whole point, and it is worth internalizing: cycle detection and depth limiting are different problems, and PHP has historically been much better at the first one.

What got fixed

The commits merged into PHP-8.4 on August 8 through 10, then forward-merged into PHP-8.5 and master, cover a specific list:

  • array_walk() and array_walk_recursive(), fixing GH-23111 via PR #23125
  • array_replace_recursive(), fixing GH-23113 via PR #23124
  • compact(), fixing GH-23115 via PR #23126
  • Array comparison through zend_hash_compare(), fixing GH-23088 via PR #23090
  • DOMNode::normalize() and Dom\Node::normalize(), fixing GH-23116 and GH-23117 via PR #23127

Several of these came from Lazizbek Ergashev, with Arnaud Le Blanc handling the merges. Arnaud is the same developer who added the stack limit machinery in the first place, back in the PHP 8.3 cycle.

The array comparison one deserves a callout because it is easy to trigger by accident. Comparing two arrays with == walks zend_compare_arrays() into zend_compare_symbol_tables() into zend_hash_compare(), once per nesting level. That path only guarded against cycles. A non-cyclic array a few tens of thousands of levels deep took down the process. Object comparison already did the right thing through zend_std_compare_objects(), so this brings arrays in line with objects.

The DOM fix has a nice implementation detail. Both normalize() methods return void, so the throw is gated on EG(exception) already being clear. Without that guard, unwinding through a tree that happens to be wide at the overflow depth would throw once per node and chain the Errors together, which turns into quadratic behavior. The fix for a denial of service should not itself be a denial of service.

Internally these all use ZEND_CHECK_STACK_LIMIT, which is the same macro ext/standard/var.c and http.c have been using for a while. This is not new machinery, it is existing machinery finally applied consistently.

Your code goes from crash to catchable

The practical upshot is that these calls now behave like the rest of PHP. Here is what changes in a request handler that accepts arbitrary JSON:

<?php

namespace PhpArch\Ingest;

final class PayloadNormalizer
{
    public function normalize(string $json): array
    {
        // json_decode has always had a depth limit. Use it.
        $data = json_decode($json, true, 64, JSON_THROW_ON_ERROR);

        try {
            array_walk_recursive($data, $this->coerce(...));
        } catch (\Error $e) {
            // On 8.4.25+ / 8.5.10+ this is reachable.
            // Before that, the worker was already gone.
            throw new PayloadTooDeep($e->getMessage(), previous: $e);
        }

        return $data;
    }

    private function coerce(mixed &$value): void
    {
        if (is_string($value)) {
            $value = trim($value);
        }
    }
}

The Error message follows the format PHP 8.3 established:

Maximum call stack size of 8339456 bytes
(zend.max_allowed_stack_size - zend.reserved_stack_size) reached.
Infinite recursion?

Note that the wording still guesses at infinite recursion, which is the common case but not the only one. Deeply nested input hits the same limit without any recursion bug on your side.

Two things worth saying plainly. First, catching \Error here is defensible but you should rethrow into your own type rather than swallow it, because the same Error can also mean you have a genuine runaway recursion elsewhere. Second, the real fix is still to bound the input before it reaches these functions. json_decode() takes a depth argument, and the default of 512 is generous for most APIs. Setting it to something your schema actually needs is cheaper than any of this.

The INI knobs, briefly

zend.max_allowed_stack_size and zend.reserved_stack_size both arrived in PHP 8.3. The default for the former is 0, meaning PHP detects the thread stack size at runtime and uses that. Setting it to -1 disables checking entirely, which puts you back in segfault territory and should be a last resort.

You mostly care about these in three situations: threaded SAPIs and runtimes like Swoole where the detected stack differs from what you expect, containers with unusual ulimit -s settings, and legitimate deep recursion in things like recursive descent parsers. If you hit the limit for a real reason, raise zend.max_allowed_stack_size rather than disabling it.

; Only if you have measured a real need.
zend.max_allowed_stack_size = 16M
zend.reserved_stack_size = 64K

Should you test the RCs

Both release candidates carry roughly five thousand changed lines across about two hundred files, which is not a small patch set for a point release. Alongside the stack work there are use-after-free fixes in implode(), XSL, user stream filters, and sockets, plus a JIT deoptimizer register preservation fix.

The current stable releases are PHP 8.5.9 and PHP 8.4.24, both from July 30, 2026. If you run anything that parses untrusted XML or accepts nested JSON, this is worth putting on a staging box now rather than waiting. The behavior change is narrow: code that previously crashed now throws. If your test suite has a case that was quietly killing the CLI process, you will suddenly see an exception where you saw nothing before. That is the improvement, but it is still a change.

Meanwhile PHP 8.6.0 Beta 1 shipped on August 13, 2026, and it inherits all of this through the merge into master. Beta 2 is planned for August 27.

Sources