Ilija Tovilo's closure optimizations RFC passed 24 to 0 with one abstention and is landing in PHP 8.6. It's two changes to how the engine handles closures, and the interesting part isn't the speedup. It's that both changes are visible from your code if you go looking, which is why the RFC exists at all.

Closures in PHP have always captured $this when they're declared inside a method, whether or not the body uses it. You can opt out by writing static function, and most people don't, either because they don't know about it or because they'd rather not add the noise. PHP 8.6 works it out for you.

Inferring static

Here's the case the RFC opens with:

class Foo {
    public $closure;

    public function __construct() {
        $this->closure = function() {
            echo "Hello world!";
        };
    }
}

That closure never mentions $this, but it captures it anyway. So Foo keeps the closure alive, the closure keeps Foo alive, and you've made a reference cycle that only the cycle collector can clean up. As the RFC puts it, those cycles are frequently "not resolved for the remainder of the request, given the cycle collector often doesn't run at all." Worse, having more of them around makes the collector more likely to run, so you pay to clean up garbage that shouldn't have existed.

In 8.6 the compiler marks that closure static on its own. It bails out and leaves the closure alone if it sees anything that could reach $this indirectly:

  • $this itself, or a variable variable like $$var, since $var could hold 'this'
  • A static call like Foo::bar(), which might be a hidden instance call to a parent method
  • A dynamic call like $f(), or call_user_func(), for the same reason
  • Another non-static closure declared inside it, since $this flows from parent to child
  • require, include, or eval, because the loaded code could do any of the above

Conservative, but it works. Tovilo tested it against Symfony Demo by stripping the static modifier off every closure, and the compiler put it back on 68 of the 87 that had it, about 78%.

Caching Stateless Closures

The second change is that a closure with no state gets reused instead of rebuilt. Stateless means static, capturing nothing, and declaring no static variables:

function test() {
    $x = static function () {};
}
for ($i = 0; $i < 10_000_000; $i++) {
    test();
}

That loop used to allocate ten million closure objects that were all identical. Now the first one is kept and handed back each time. That specific benchmark is synthetic and improves about 80%, so take it for what it is. The number worth quoting is from the Laravel template, where the two changes together skip 2384 of 3637 closure instantiations and land around 3% overall.

Two Closures Can Now Be the Same Object

This is the part that got argued about. Because stateless closures are cached, two closures created from the same line of code are now the same instance:

function test() {
    return function () {};
}

test() === test(); // true

That used to be false. The RFC lists it as one of three accepted backward compatibility breaks, and when the RFC hit Hacker News it was the thing commenters kept circling back to.

eurleif pointed out that JavaScript specifically forbids this. The equivalent there has to log false in a compliant implementation, because each evaluation of a function expression produces a distinct object.

ragnese took that further and argued it's a correctness problem rather than a curiosity:

Anonymous functions are instances of a Closure class, which means that the === operator should return false for foo() === foo() just like it would for new MyClass() === new MyClass().

user3939382 pushed back on whether it shows up in practice:

Does that look like the code you're writing for some reason? Because I've seen 100k loc enterprise PHP apps that not once ran into that as an issue.

That's roughly where it landed. Comparing two closures by identity is rare, and if you're doing it to deduplicate callbacks or key a cache by closure, this is the change that will surprise you. Everyone else won't notice.

The Other Two Breaks

ReflectionFunction::getClosureThis() returns NULL for a closure that got inferred as static. The RFC is upfront that this is a little odd, since inference isn't perfect and a closure's status can flip based on an edit somewhere else in the method.

Destructors can also run earlier. Objects that used to sit in an uncollected cycle now get freed when the last real reference goes away. The RFC calls this "generally expected and more predictable," and it is, but if you have cleanup logic in a __destruct() that's been running late for years, it will start running on time.

There's one deliberate softening. Closure::bind() and Closure::bindTo() normally throw if you bind an object to a static closure. For inferred closures they accept the object and quietly discard it instead, so a closure that becomes static because you deleted an unrelated static method call doesn't start throwing at runtime.

When You Get It

The implementation is merged and the RFC is marked accepted and landing, so this ships with PHP 8.6 in November. Nothing to turn on, and nothing to change in your code unless you're comparing closures with ===.

If you want to keep the current behavior for a specific closure, give it something to capture. A closure that captures a variable or declares a static variable isn't stateless, so it won't be cached. And explicitly writing static function is still worth doing, since it documents the intent and doesn't depend on the compiler proving it for you.

Read the full RFC for the complete inference rules, and see our PHP 8.6 feature roundup for what else is coming.