Closures in Constant Expressions: PHP 8.5's Quiet Power Feature
PHP 8.5 lets you place static closures and first-class callables in constant expressions, so attributes and default arguments can finally carry real logic.
Most of the PHP 8.5 coverage went to the pipe operator and the URI extension, and fairly so. But there is a smaller change in the release that quietly removes a limitation many of us have worked around for years without really noticing. As of PHP 8.5, which shipped on November 20, 2025, you can place closures inside constant expressions. That means attribute arguments, default parameter values, property defaults, and class constants can now carry a Closure directly. It sounds academic until you hit the places where it matters, and then it feels overdue.
What “constant expression” actually means
PHP restricts certain positions in the language to constant expressions. These are the spots evaluated once, at compile time or class-load time, rather than on every call. Attribute arguments are the classic example, but the list also includes default values for function parameters and properties, plus constant and class-constant definitions. Historically the set of allowed operations was limited to things PHP could treat as immutable values: scalars, arrays of scalars, other constants, and since PHP 8.1, enum cases and new expressions in initializers.
Closures were never on that list. If you tried to write one as an attribute argument or a property default, you got a compile-time error. The reasoning was reasonable enough: a closure can capture variables and reference $this, neither of which makes sense in a position evaluated once with no surrounding runtime scope. The RFC authors, Tim Duesterhus and Volker Dusch, made the case that a closure is really just compiled opcodes, and opcodes are an immutable value if you strip away the parts that need a live scope. The vote passed 19 to 0, which tells you how uncontroversial the idea was once the constraints were nailed down.
The two rules you have to follow
There are exactly two constraints, and both are checked at compile time so you cannot get them wrong silently.
First, the closure must be static. A non-static closure can bind $this, and $this only has meaning per-instance. Since a constant expression is evaluated once and shared, binding an object to it would be incoherent. Marking the closure static removes $this from the picture entirely.
Second, the closure may not capture variables from an outer scope. That rules out use ($foo) and, importantly, it also rules out arrow functions. Short closures perform implicit capturing by design, so fn ($x) => ... is not permitted here even though it looks tidier. You have to reach for the full static function () { ... } form.
Both rules follow the same logic: a constant expression has no surrounding runtime state to draw on, so anything that would need such state is off the table.
The simplest win: default callbacks
Before 8.5, giving a function a default callback meant a small dance. You accepted a nullable Closure, defaulted it to null, then filled it in inside the body.
function my_array_filter(array $array, ?Closure $callback = null): array
{
$callback ??= static fn ($item) => !empty($item);
return array_values(array_filter($array, $callback));
}
That works, but the signature lies a little. It says the callback is optional and nullable when what you really mean is “there is a sensible default.” In 8.5 you can state the default where it belongs:
function my_array_filter(
array $array,
Closure $callback = static function ($item) { return !empty($item); },
): array {
$result = [];
foreach ($array as $item) {
if ($callback($item)) {
$result[] = $item;
}
}
return $result;
}
The parameter is now genuinely a Closure, not a ?Closure, and the default documents itself in the signature. Small change, but it tightens the contract.
Where it gets interesting: attributes
The real payoff is attributes. Attributes only accept constant expressions, so until now you could pass a class name or a scalar to describe behavior, but never the behavior itself. You would reference a validator by string and resolve it later through a registry. Now the logic can live inline.
Here is a validation attribute carrying its own rule, taken from the RFC’s own examples:
final class Locale
{
#[Validator\Custom(static function (string $languageCode): bool {
return (bool) preg_match('/^[a-z][a-z]$/', $languageCode);
})]
public string $languageCode;
}
The validation library reads the attribute, pulls out the closure, and runs it against the property value. No string indirection, no separate registration step. The same pattern fits a serialization formatter or a test-case generator equally well.
You can also nest closures inside larger constant expressions, such as an array of transformations used as a default:
function pipeline(
string $input,
array $callbacks = [
static function ($value) { return strtoupper($value); },
static function ($value) { return preg_replace('/[^A-Z]/', '', $value); },
],
): string {
foreach ($callbacks as $callback) {
$input = $callback($input);
}
return $input;
}
echo pipeline('Hello, World!'); // HELLOWORLD
First-class callables count too
The change is not limited to inline closure bodies. First-class callable syntax works in the same positions, so strtolower(...) or SomeClass::normalize(...) are valid constant expressions now as well. This arrived through a companion RFC that was split off from the main one for voting reasons rather than technical ones, and it landed in the same 8.5 release. One limitation to keep in mind: only references to free-standing functions and static methods are supported, so function_name(...) and ClassName::methodName(...) work, but instance-method references do not. It is handy when the behavior you want already exists as a named function or static method and you would rather point at it than re-wrap it.
A note on scope and Opcache
Closures defined this way still honor the scope of where they sit. A closure in a property default or an attribute on a class can reach that class’s private members, the same way a closure created in the constructor could. That keeps the feature useful for encapsulated logic rather than forcing everything public.
On the operational side, the RFC required Opcache to learn how to store these closures in shared memory, and that work landed with the feature. It passes the test suite with Opcache and JIT enabled, so there is no runtime caveat to worry about in production. One thing to watch: because this turns previously invalid syntax into valid code, static analyzers and IDEs need updating to stop flagging it. If your tooling complains, check for a version that understands 8.5 before assuming the code is wrong.
Worth adopting
This is not a feature you will use every day, and that is fine. It is the kind of change that removes a paper cut you had stopped noticing. When you next build an attribute-driven library, or reach for a nullable-then-defaulted callback, remember that the cleaner form is now available. It is a good example of PHP filling in a gap in the language’s internal consistency rather than bolting on something flashy, and those changes tend to age well.