6 min read

PHP 8.6 Caches Stateless Closures, but Only the Ones You Marked static Yourself

The closure optimizations RFC passed 24 to 0 and then shipped half. Static inference was pulled in August, so typing static on your closures is back on you.

Featured image for "PHP 8.6 Caches Stateless Closures, but Only the Ones You Marked static Yourself"

Most RFCs that pass 24 to 0 ship exactly what they promised. Ilija Tovilo’s closure optimizations RFC is the exception this cycle. It cleared voting on March 13, 2026 with zero dissent, and then in August, four months later, half of it was quietly pulled before it reached the 8.6 branch.

The half that survived is still worth having. But which half survived changes what you should do about it, and if you read a write-up from the spring you probably have the wrong idea.

The two halves

The RFC bundled two independent optimizations, both aimed at the same thing: PHP allocates a lot of closure objects it does not need to.

Static closure inference would have made PHP figure out on its own that a closure never touches $this, and mark it static for you. That matters because a non-static closure declared inside a method implicitly captures $this, which keeps the object alive for as long as the closure lives. If the object also holds a reference to the closure, you have a reference cycle that only the garbage collector can break, and the cycle collector frequently does not run at all before the request ends.

Stateless closure caching is narrower. If a closure is static, captures nothing, and declares no static variables, then every instance of it is functionally identical, so PHP keeps one around and reuses it instead of allocating a fresh object each time the declaration is evaluated.

Why inference was pulled

The inference rules were conservative already. A closure could only be inferred as static if it did not use $this, did not use $$var (because that variable could hold the string this), did not make a Foo::bar() style call or a $f() call or a call_user_func() (any of which could turn out to be a hidden instance call), did not declare another non-static closure, and did not use require, include, or eval.

Those rules were effective. The RFC reports a test against Symfony Demo where every explicit static modifier was stripped from the codebase and the optimizer recovered 68 of the 87 closures that had been marked static by hand, about 78 percent.

The edge case that killed it, documented in an errata dated August 11, 2026, is uglier than any of the seven rules. Internal functions can perform instance calls through a previous stack frame using nothing but a named callable string:

class Foo
{
    public function instanceCall()
    {
        return $this;
    }

    public function test($c)
    {
        return (function () use ($c) {
            return array_map($c, [1]);
        })();
    }
}

var_dump((new Foo)->test('Foo::instanceCall'));
// array(1) { [0] => object(Foo)#1 }

Nothing in that closure body looks like an instance call. array_map() is what reaches back and supplies $this. The same applies to many other internal functions and to array callables like ['Foo', 'instanceCall']. Tovilo’s position was that this behavior should be deprecated and removed before the engine starts assuming nobody depends on it, and rather than ship inference that silently breaks in this shape, only the caching half was merged.

Fair call. It also means the RFC’s headline number does not apply to you.

About that 3 percent

The RFC measured the Laravel template and found the two optimizations together avoided 2,384 of 3,637 closure instantiations, worth roughly 3 percent on the author’s machine. That figure was for both halves. With inference gone, the caching half only fires on closures that are already explicitly static, and most application code does not bother. The 3 percent is a ceiling you have to opt into, not a freebie you get by bumping the version in your Dockerfile.

Which brings us to the actual takeaway.

Type the keyword

The performance benefit of PHP 8.6’s closure cache is now entirely gated on you writing five characters. Three conditions, all of which must hold:

// Cached. Static, captures nothing, declares no static variables.
$fmt = static fn (int $cents): string => number_format($cents / 100, 2);

// Not cached. Captures $rate by value.
$fmt = static fn (int $cents): float => $cents * $rate;

// Not cached. Declares a static variable.
$fmt = static function (): int {
    static $calls = 0;
    return ++$calls;
};

// Not cached, and inside a method it drags $this along too.
$fmt = fn (int $cents): string => number_format($cents / 100, 2);

Arrow functions count as closures here, so static fn is doing real work and is not just noise.

The place this pays off is closures declared inside something that runs repeatedly. A closure declaration inside a loop body, a mapping callback in a method called once per row, a comparator handed to usort() in a request-scoped service. In a php[architect] issue exporter, for instance:

final class IssueExporter
{
    public function export(array $articles): array
    {
        usort($articles, static fn (Article $a, Article $b) => $a->page <=> $b->page);

        return array_map(
            static fn (Article $a) => [
                'title'  => $a->title,
                'author' => $a->author,
                'page'   => $a->page,
            ],
            $articles,
        );
    }
}

Drop the two static keywords and each of those closures captures $this, which is an IssueExporter reference held for the closure’s lifetime, plus a fresh allocation every call. Neither closure needs the instance. On 8.6 they are also both cache-eligible with the keyword and both ineligible without it.

One behavior change to know about

Caching makes two closures from the same lexical position identical:

function make(): Closure {
    return static function () {};
}

var_dump(make() === make()); // true on 8.6, false before

This is listed in the RFC as a backward incompatible change, and it is the one most likely to bite real code. If you deduplicate listeners or callbacks by putting them in an SplObjectStorage, or key anything on spl_object_id() of a closure, two callbacks you consider distinct can now collapse into one entry. Anonymous stateless closures were never a great identity key, but plenty of event dispatchers and hook systems have used them as one because it happened to work.

The second BC note is friendlier. Objects that would previously have been trapped in a closure cycle can now be collected earlier, which means destructors fire earlier too. Technically a change, generally what you wanted.

Timing

PHP 8.6 hit Beta 1 and its soft feature freeze on August 13, 2026, with RC1 scheduled for September 24 and general availability targeted for November 19. The closure work is in the branch now. The RFC status reads “Partially implemented (see errata),” which is unusual phrasing for the wiki and a good reminder to check RFC pages for errata rather than trusting a spring summary.

If you maintain a library, this is a cheap win to bank before GA. Run your test suite on a beta, then go find the closures in your hot paths and add the keyword. You do not have to do it by hand either. Rector ships StaticClosureRector and StaticArrowFunctionRector in its CodingStyle set, both added back in 0.13.9, and both do exactly this transformation where $this is unused. Tooling has been nudging you toward static for a while on cycle-collection grounds. Starting in November, the engine pays you for it too.

Sources: PHP RFC: Closure optimizations, Closure optimizations, PHP.Watch, PHP 8.6 RFC List, PHP.Watch, php-src PR #19941, Closure optimizations RFC discussion, The PHP Foundation Discourse, PHP 8.6’s soft feature freeze, php[architect].