Use Closures in Constant Expressions in PHP 8.5

PHP 8.5 closures in constant expressions let you put a static closure in an attribute, a default parameter or a class constant. Here are the rules and traps.

Steven Richardson
Steven Richardson
· 7 min read

I have written the same ugly workaround more times than I want to admit: an attribute that needs a callback, so it takes a string function name, and something further down the stack has to is_callable() it and hope. PHP 8.5 removes the need. Closures are now legal constant expressions, so the callback goes inline where you declare it.

There are two rules that catch everyone, and both fire at compile time.

What changed: closures in constant expressions in PHP 8.5#

A "constant expression" is the restricted grammar PHP allows in places it evaluates once, at compile time. Attribute arguments. Default parameter and property values. Constants and class constants. Closures were never part of that grammar, so this was a fatal error before 8.5:

function my_array_filter(
    array $array,
    Closure $callback = static function ($item) { return ! empty($item); },
) {
    // ...
}

As of PHP 8.5 that compiles. The Support Closures in constant expressions RFC reasons that a closure is really just opcodes — an immutable value — so there is no principled reason to keep it out. It landed alongside the rest of the 8.5 feature set on 20 November 2025.

The four places it now works:

// 1. Default parameter value
function transform(
    string $in,
    Closure $fn = static function (string $v): string { return trim($v); },
): string {
    return $fn($in);
}

// 2. Default property value
class Handler
{
    public Closure $onFailure = static function (Throwable $e): void {
        error_log($e->getMessage());
    };
}

// 3. Class constant
class Pipeline
{
    public const array STAGES = [
        static function (string $v): string { return trim($v); },
        static function (string $v): string { return strtolower($v); },
    ];
}

// 4. Attribute argument
#[Validator(static function (string $code): bool {
    return preg_match('/^[a-z]{2}$/', $code) === 1;
})]
public string $languageCode;

The static, no-use restriction#

Two constraints, both checked by the compiler.

No use() clause. A constant expression has no surrounding scope to capture from, so importing variables is meaningless. This has a consequence people miss: arrow functions are banned, because fn() captures implicitly.

class Config
{
    // Fatal error: arrow functions capture implicitly
    public const Closure NORMALISE = static fn (string $v): string => trim($v);

    // Fine
    public const Closure NORMALISE_OK = static function (string $v): string {
        return trim($v);
    };
}

That one stings, because static fn looks like it captures nothing. The compiler doesn't inspect the body to find out — implicit capture is a property of the syntax, so fn is rejected on sight.

Must be static. A non-static closure binds $this, and $this differs per object instance. Constant expressions are evaluated once, so per-instance rebinding would break the guarantee. Drop static and you get a compile error:

class Report
{
    // Fatal error: closure must be static
    public Closure $formatter = function (float $n): string {
        return number_format($n, 2);
    };
}

Both errors surface when the file is compiled, not when the property is read. That is the good outcome — you find out on deploy, not three weeks later in a queue worker.

Closures as attribute arguments and defaults#

Attribute arguments are where this earns its keep. Before 8.5, an attribute that needed behaviour took a string and resolved it later, which meant no type checking and no IDE navigation. Now the behaviour sits in the declaration:

#[Attribute(Attribute::TARGET_PROPERTY)]
final class Sanitize
{
    public function __construct(public Closure $using) {}
}

final class ContactForm
{
    #[Sanitize(using: static function (string $value): string {
        return preg_replace('/\s+/', ' ', trim($value));
    })]
    public string $name = '';

    #[Sanitize(using: strtolower(...))]
    public string $email = '';
}

Reading them back is ordinary reflection — newInstance() materialises the closure:

$property = new ReflectionProperty(ContactForm::class, 'name');

foreach ($property->getAttributes(Sanitize::class) as $attribute) {
    $sanitiser = $attribute->newInstance()->using;

    $clean = $sanitiser('  Steven   Richardson ');
}

If you are building attribute-driven behaviour more broadly, this pairs naturally with the patterns in PHP custom attributes beyond Laravel's built-ins — the reflection plumbing is identical, you are just storing a Closure instead of a scalar.

One nuance worth knowing: a closure in a constant expression follows the scoping rules of where it is written. A closure in a property default or attribute argument can call private methods and read private constants of the declaring class. It behaves like a closure defined in the constructor, not like a detached function.

First-class callables in constant expressions#

The original RFC left first-class callables out and parked them in future scope. A follow-up RFC shipped them in the same release, so strlen(...) is also a valid constant expression in 8.5.

final class Formatter
{
    public const Closure ESCAPE = htmlspecialchars(...);

    public Closure $slugify = self::toSlug(...);

    private static function toSlug(string $value): string
    {
        return strtolower(str_replace(' ', '-', $value));
    }
}

The FCC form carries extra constraints beyond the closure ones:

  • Only free-standing functions and static methods (ClassName::method(...)). Instance methods are out — there is no object to bind.
  • The name must be a literal, not an expression. [Formatter::class, 'toSlug'](...) and (SOME_CONSTANT)(...) are both rejected.
  • Methods that only exist via __callStatic() are not supported. The compiler needs a real declaration to point at.

Autoloading still works — referencing a not-yet-loaded class in an FCC triggers the autoloader, the same as new in a constant expression. And relative function names in a namespace resolve namespace-first, falling back to global, which is the normal FCC behaviour.

If first-class callable syntax itself is new to you, I covered the everyday use in first-class callable syntax for cleaner Laravel collection pipelines. This is the same syntax, in a place it previously could not go.

Where PHP 8.5 closures in constant expressions help in Laravel#

Two patterns have earned their place in my own code.

Config-as-code on a class constant. A transformation pipeline that used to live in a config array as string callables becomes typed and greppable:

final class ImportNormaliser
{
    public const array STAGES = [
        'reference' => strtoupper(...),
        'notes' => static function (?string $value): string {
            return $value === null ? '' : trim($value);
        },
    ];

    public function normalise(array $row): array
    {
        foreach (self::STAGES as $field => $stage) {
            $row[$field] = $stage($row[$field] ?? null);
        }

        return $row;
    }
}

Attribute-driven behaviour on DTOs and jobs. Anywhere you were reaching for a string callable or a match in a resolver, the closure now lives next to the thing it describes. It is the same instinct behind marking methods with PHP 8.4's #[\Deprecated] attribute — put the metadata where the reader is already looking.

Worth being honest about the trade-off. A closure in a class constant is not serialisable, cannot be dumped into a cached config file, and cannot be overridden by environment. If the behaviour genuinely varies per environment, it belongs in config, not in a constant. I reach for this when the behaviour is a property of the code, not of the deployment.

Gotchas and Edge Cases#

It is not the lazy-initialisation replacement it looks like. The closure value is created once at compile time, but the work inside it still runs on every call. If your goal is deferring expensive construction, PHP 8.4 lazy objects are the right tool, not a constant closure.

Static analysers needed updating. Every RFC that turns a compile error into valid syntax breaks tooling until it catches up. PHPStan added support for static closures in constant expressions in 2.1.32 — on an older version you will get false positives on perfectly valid code. If your CI is pinned behind a baseline, this is exactly the kind of noise that makes a PHPStan baseline burn-down painful. Bump the analyser before you adopt the syntax.

Recursion has no use escape hatch. The classic recursive-closure trick relies on use (&$fn), which is unavailable here. PHP 8.5 ships Closure::getCurrent() for exactly this, so a self-referential static closure is still possible — just via the new API rather than capture.

Typed constants make it readable. Declaring public const Closure ESCAPE = ... rather than an untyped constant tells the reader and the analyser what they are getting. Typed class constants have been available since 8.3 and this is a good place to use them.

Do not confuse "evaluated once" with "cheap". Opcache stores these closures in shared memory, which is why the RFC needed opcache changes. That is handled, but it does mean a closure in a constant expression is genuinely part of your compiled code — treat it with the same care as any other code you cannot hot-patch.

Wrapping Up#

The rule to remember is short: static, no use, no arrow functions, and first-class callables only for functions and static methods. Everything else follows from those. Start with attribute arguments — that is where the old string-callable workaround was ugliest and the payoff is immediate.

If you are working through the rest of the release, PHP 8.5's #[\NoDiscard] attribute and asymmetric visibility for static properties are the two other changes that have altered how I write day-to-day code.

FAQ#

What are closures in constant expressions in PHP 8.5?

A constant expression is the limited set of values PHP evaluates once at compile time — attribute arguments, default parameter and property values, and constants. Before PHP 8.5, a closure could not appear in any of them. PHP 8.5 makes closures a legal part of that grammar, so you can declare behaviour inline instead of passing a string function name and resolving it later.

Why must the closure be static with no use clause?

A use() clause captures variables from the surrounding scope, and a constant expression has no surrounding scope to capture from. A non-static closure binds $this, which varies per object instance, whereas a constant expression is evaluated only once. Both restrictions preserve the guarantee that the value is immutable and evaluated a single time. The compiler verifies both, so violations are fatal at compile time rather than runtime.

Where can I use a closure constant expression?

Four places: attribute parameters, default values of function and method parameters, default values of properties, and constants including class constants. Closures can also appear inside sub-expressions in those positions, so an array of closures as a default parameter value or a closure passed to a new expression in a constant both work.

Can I use first-class callables in constant expressions?

Yes, added by a follow-up RFC in the same PHP 8.5 release. The restrictions are tighter than for closures: only free-standing functions and static methods referenced as ClassName::method(...) are allowed. The function name must be a literal rather than an expression, so array-callable syntax is rejected, and methods that exist only through __callStatic() are not supported.

Which PHP version adds closures in constant expressions?

PHP 8.5, released on 20 November 2025. Closures came from the "Support Closures in constant expressions" RFC and first-class callables from a separate follow-up RFC that shipped in the same version. There is no backward-compatibility break, since all of this was previously a compile-time error.

Steven Richardson
Steven Richardson

CTO at Digitonic. Writing about Laravel, architecture, and the craft of leading software teams from the west coast of Scotland.