DEV Community

Cover image for Closures in Constant Expressions: What PHP 8.5 Unlocks for Attributes
Gabriel Anhaia
Gabriel Anhaia

Posted on

Closures in Constant Expressions: What PHP 8.5 Unlocks for Attributes


You have shipped this attribute before. #[Validate('email')] on a property, then a validator service somewhere else with a match that maps the string 'email' to a filter_var call. The attribute holds a label. The behavior lives three files away, keyed by that label. Add a rule and you edit two places that have to agree, and nothing tells you when they drift.

The reason you wrote it that way is that attribute arguments are constant expressions, and until now a closure was not a constant expression. You could pass a string and resolve it later. You could not pass the function itself.

PHP 8.5 changes that. Closures are now legal in constant expressions, which means they are legal in attribute arguments. The behavior can sit on the declaration instead of in a registry.

What the constant-expression boundary used to block

An attribute argument has always accepted scalars, arrays, ::class references, enum cases, and other constants. PHP 8.1 added new in initializers, so you could construct objects there too. What you could never write was a function.

That single gap is why so much attribute code is indirection. The attribute names a thing, and a separate resolver turns the name into behavior. It works, but the type system does not connect the label to what runs, so a typo in the string is a runtime surprise.

With closures allowed in constant expressions, the function moves onto the attribute directly:

#[Attribute(Attribute::TARGET_PROPERTY)]
final class Validate
{
    public function __construct(
        public Closure $rule,
        public string $message,
    ) {}
}
Enter fullscreen mode Exit fullscreen mode

The attribute now carries the rule, not a key that points at one.

The four rules you have to respect

The feature comes with constraints, and they matter because they shape how you write the closures.

  • The closure must be static. It has no $this.
  • It cannot capture with use (...). Constant expressions have no surrounding variables to capture.
  • Arrow functions are rejected. fn performs implicit capture, so the whole short-closure form is out, even when it would capture nothing.
  • First-class callable syntax is allowed. intval(...) and Money::fromCents(...) both work as constant expressions.

So every closure you inline in an attribute looks like static function (...) { ... }, and every dependency it needs arrives as an argument. That second rule is the one that reads like a limitation and turns out to be a design nudge.

Validation on the declaration

Here is the full loop with no registry in the middle. The rule sits next to the field it guards.

final class Signup
{
    #[Validate(
        static function (string $v): bool {
            return filter_var(
                $v, FILTER_VALIDATE_EMAIL
            ) !== false;
        },
        'Enter a valid email address.',
    )]
    public string $email = '';

    #[Validate(
        static function (string $v): bool {
            return strlen($v) >= 12;
        },
        'Use at least 12 characters.',
    )]
    public string $password = '';
}
Enter fullscreen mode Exit fullscreen mode

The engine reads the attributes and runs each closure against the property value:

function validate(object $target): array
{
    $errors = [];
    $ref = new ReflectionObject($target);

    foreach ($ref->getProperties() as $prop) {
        $value = $prop->getValue($target);
        $attrs = $prop->getAttributes(Validate::class);

        foreach ($attrs as $attr) {
            $rule = $attr->newInstance();
            if (($rule->rule)($value) === false) {
                $errors[$prop->getName()][] =
                    $rule->message;
            }
        }
    }

    return $errors;
}
Enter fullscreen mode Exit fullscreen mode

($rule->rule)($value) is the invocation, with parentheses around the property access so PHP calls the closure rather than looking for a method. The rule for email runs on the email field because they share a declaration. There is no string to keep in sync.

Field mapping without a transformer service

The same shape covers hydration. You get a raw array from a query or a JSON payload, and you want typed properties out of it. Attach the source key and, when needed, the cast that converts the raw value.

#[Attribute(Attribute::TARGET_PROPERTY)]
final class MapFrom
{
    public function __construct(
        public string $key,
        public ?Closure $cast = null,
    ) {}
}
Enter fullscreen mode Exit fullscreen mode
final class OrderView
{
    #[MapFrom('order_id')]
    public string $id;

    #[MapFrom('placed_at', cast: static function (
        string $raw
    ): DateTimeImmutable {
        return new DateTimeImmutable($raw);
    })]
    public DateTimeImmutable $placedAt;

    #[MapFrom('total_cents', cast: intval(...))]
    public int $totalCents;
}
Enter fullscreen mode Exit fullscreen mode

Notice the last line. intval(...) is a first-class callable, which is a valid constant expression, so the trivial casts stay one token wide instead of a full closure body. The hydrator walks the attributes and applies each cast:

function hydrate(string $class, array $row): object
{
    $ref = new ReflectionClass($class);
    $obj = $ref->newInstanceWithoutConstructor();

    foreach ($ref->getProperties() as $prop) {
        $attrs = $prop->getAttributes(MapFrom::class);
        if ($attrs === []) {
            continue;
        }

        $map = $attrs[0]->newInstance();
        $raw = $row[$map->key] ?? null;
        $value = $map->cast
            ? ($map->cast)($raw)
            : $raw;

        $prop->setValue($obj, $value);
    }

    return $obj;
}
Enter fullscreen mode Exit fullscreen mode

The mapping table that used to live in a Transformer class is now spread across the properties it describes, one line each.

Routing guards and DI factories

The clearest case where inline closures earn their keep is routing. A route often needs a small authorization check that is specific to that one endpoint. Before 8.5 you named a middleware or a policy string. Now the check rides on the route.

#[Attribute(Attribute::TARGET_METHOD)]
final class Route
{
    public function __construct(
        public string $method,
        public string $path,
        public ?Closure $guard = null,
    ) {}
}
Enter fullscreen mode Exit fullscreen mode
final class ReportController
{
    #[Route('GET', '/reports/{id}',
        guard: static function (
            Context $ctx
        ): bool {
            return $ctx->user?->hasRole('analyst')
                ?? false;
        },
    )]
    public function show(string $id): Response
    {
        // ...
    }
}
Enter fullscreen mode Exit fullscreen mode

The dispatcher runs the guard before the handler, passing whatever context it holds:

if ($route->guard
    && ($route->guard)($ctx) === false) {
    return Response::forbidden();
}
Enter fullscreen mode Exit fullscreen mode

Dependency injection is where the no-capture rule pays off. A factory attribute cannot bake the container into the closure with use, so the container has to come in as a parameter. The dependency stays explicit instead of hiding in a captured variable.

#[Attribute(Attribute::TARGET_CLASS)]
final class Factory
{
    public function __construct(
        public Closure $make,
    ) {}
}

#[Factory(static function (
    Container $c
): PdoConnection {
    return new PdoConnection(
        $c->get('db.dsn'),
        $c->get('db.user'),
    );
})]
final class PdoConnection
{
    public function __construct(
        string $dsn,
        string $user,
    ) {
        // ...
    }
}
Enter fullscreen mode Exit fullscreen mode

The container reads the attribute and calls the factory with itself:

$attrs = (new ReflectionClass($id))
    ->getAttributes(Factory::class);

if ($attrs !== []) {
    $make = $attrs[0]->newInstance()->make;
    return $make($this);
}
Enter fullscreen mode Exit fullscreen mode

The gotchas worth knowing before you commit

newInstance() builds a fresh closure every time you call it. If you read the same attribute on every request, resolve it once and cache the result, and do not rely on two reads returning the same closure object.

Closures do not serialize. If your resolved attribute lands in a cache that uses serialize() or var_export(), it fails. Cache the outcome of running the closure, not the closure.

The Closure type on the attribute property tells static analysis nothing about the signature. A tool sees Closure, not Closure(string): bool. Add a docblock or a PHPStan template if you want the parameter and return types checked at the call site.

And the arrow-function ban is a real papercut. Even a capture-free fn ($x) => $x > 0 is rejected, so you write the longer static function form for the smallest predicates. PHP-CS-Fixer had to teach its arrow-function rule to skip constant expressions for exactly this reason.

When to reach for it, and when not

Inline closures fit property-local, one-off logic. A validation rule that guards a single field, a cast used by one DTO, a guard that belongs to one route. The behavior is short, it is used once, and it reads best next to the thing it describes.

When a rule repeats across many properties, an inline closure copied five times is worse than a named static method referenced by first-class callable syntax. Write the function once, then point at it with Rules::minLength(...). You keep the declarative style and lose the duplication.

Closures in constant expressions do not replace your validation library or your router. They remove one layer of indirection from the ones you hand-roll, and they let the attribute say what it does instead of naming a key that means something elsewhere.


The reason this reads clean is that validation and mapping are edge concerns, and PHP 8.5 lets you keep them at the edge, declared on the boundary types that carry data in and out. The domain underneath stays free of framework glue and reflection. Drawing that line between the declarative shell and the core is the whole subject of Decoupled PHP, which spends its chapters on where to put concerns like these so a framework upgrade never reaches your business rules.

Decoupled PHP — Clean and Hexagonal Architecture for Applications That Outlive the Framework

Available on Kindle, Paperback, and Hardcover. English, German, and Japanese editions out now — Portuguese and Spanish coming soon.

Top comments (0)