Ecotone's support for Closures in Attributes
Replace expression strings and one-off middleware with typed closures in PHP attributes: dynamic delays, headers, and SQL parameters in one line, checked by PHPStan.
Updated on 2026-07-20
You need a message delayed by whatever the command says: $command->delayInMilliseconds. That is one line of logic. Until now, expressing it meant either an expression string your IDE cannot see into, or a middleware class with a constructor, a registration, and a file of its own. This is a walk through what that exchange rate used to look like in practice, and what it looks like now that Ecotone executes real closures inside attributes.
TL;DR: Everyexpressionproperty in Ecotone now accepts a typedClosureinstead of a Symfony expression string. Closures run with message-handler-style parameter resolution (#[Payload],#[Header],#[Reference]injection), so a dynamic delay, header, dedup key, or SQL parameter is one refactorable line where it is needed, with no middleware.
Table of contents
- Before: the price of one computed value
- After: the delay that reads itself
- Deriving a header with a service
- SQL parameters from two fields
- Why the closures stay small
- When I would still write the string
- Wrapping up
Before: the price of one computed value
Declarative configuration is a good deal right up until the first value that has to be computed. A static delay is one attribute argument. A delay that depends on the command payload used to give you two options.
Option one, the expression string:
#[Delayed(expression: 'payload.delayInMilliseconds')]
Looks harmless. It is a string.
Rename delayInMilliseconds to delayMs and every real usage updates through your refactoring tool, except this one. PHPStan cannot check it. Autocomplete cannot complete it. The break is silent and surfaces at runtime, only on the code path that dispatches this particular command. In Symfony land the community verdict on expression syntax is on record: "even more ugly (and goodbye to static analysis)."
Option two, the middleware class. A real class implementing the middleware contract, reading the payload, attaching a delay stamp, registered in dispatch configuration. It type-checks beautifully. It is also a separate file, in a separate layer, executed at a distance from the handler it configures, and it exists to carry one line of logic.
Both directions are compensating for the same gap, which we can solve with Closures in Attributes.
After: the delay that reads itself
PHP 8.5 made closures legal inside attributes, and Ecotone PR #678 makes them work as expressions. The same dynamic delay:
#[Delayed(expression: static function (#[Payload] NotifyCustomer $command): int {
return $command->delayInMilliseconds;
})]
#[Asynchronous('async')]
#[CommandHandler('customer.notify', endpointId: 'customerNotifyEndpoint')]
public function notify(NotifyCustomer $command): void {}
The delay logic sits on the handler it delays, typed end to end.
Three things changed, and all of them are tooling-visible. The parameter is typed NotifyCustomer, so autocomplete works inside the closure. The return type is int, so PHPStan verifies you are returning something #[Delayed] can use. And the property access is real code, so a rename refactor reaches it. The one line of logic costs one line.
Note what did not change: the handler itself is untouched. No fake parameters, no base-class hooks, no getDelay() override. The delayed messaging behavior is still declared where the handler is declared.
Deriving a header with a service
The above was a simple case, because in most the cases it maps to single property. The more interesting case is a value that needs a collaborator.
Now the closure asks for what it needs:
#[CommandHandler('order.place')]
public function placeOrder(
PlaceOrder $command,
#[Header('token', expression: static function (#[Header('token')] string $token, #[Reference] TokenService $tokenService): string {
return $tokenService->normalize($token);
})] string $token,
): void {}
#[Header] binds the raw header, #[Reference] injects the service; the handler receives the normalized value.
SQL parameters from two fields
The same mechanism reaches into the DBAL business interface. Building a SQL parameter from two method arguments used to be an expression string doing string concatenation inside a string, which is as pleasant as it sounds. Now:
#[DbalWrite('INSERT INTO persons (person_id, name) VALUES (:personId, :fullName)')]
#[DbalParameter('fullName', expression: static function (string $firstName, string $lastName): string {
return ucfirst($firstName) . ' ' . ucfirst($lastName);
})]
public function insert(int $personId, string $firstName, string $lastName): void;
Closure parameters map to the interface method's arguments by name.
The full list of attributes that accept closures now: #[Payload], #[Header], #[Reference], #[Fetch], #[AddHeader], #[Delayed], #[TimeToLive], #[Deduplicated], #[DbalParameter], and #[WithTenantResolver]. Dynamic dedup keys and per-tenant connection resolution fall out of the same feature — the two cases that generated the loudest "attributes are static by definition" complaints in the Laravel multi-tenant world.
Why the closures stay small
The design decision that makes this hold up in practice: closures are executed like message handlers, not like bare callbacks. The new AttributeExpressionExecutor runs each closure through the same parameter-resolution pipeline handlers use. #[Payload] converts the payload, #[Header] and #[Headers] bind metadata, #[Reference] resolves services, #[ConfigurationVariable] pulls configuration, a Message type-hint hands over the whole message, and an unannotated first parameter defaults to the payload.
That pipeline is why every closure in this article is one statement long. Conversion, binding, and lookup are the framework's job; the closure holds only the derivation. It also means there is no new API to learn — if you know how Ecotone handler parameters resolve, you already know how attribute closures resolve. One mental model.
The trade-offs, named. Closure expressions are an Enterprise feature; without a licence, bootstrap throws a LicensingException immediately rather than letting the first production message fail. Expression strings still work (the properties are string|Closure), so nothing forces a migration. And under the hood the container never serializes your closure; it stores a reference to the declaring class, method, or parameter and re-reads the attribute via reflection, which is what keeps cached containers working.
When I would still write the string
Honesty section. If the expression is 'payload.id', the string is shorter and the closure buys you little. Where closures earn their keep is anywhere a rename could land, anywhere a type conversion matters, and anywhere you were about to write infrastructure. And where neither belongs: actual business logic. A closure with branching in an attribute is business logic hiding in an annotation; that goes in the handler. The feature is for derivations, and the moment a derivation stops fitting on one line, extract it into a service and inject it with #[Reference].
Wrapping up
The exchange rate was the problem: one line of logic for one class of infrastructure, or one line of logic for one unanalyzable string. Typed closures in attributes bring it back to one for one. Try converting a single expression string in a codebase you own and run PHPStan on the result — the diff is small and the difference in confidence is not.
About the author: Dariusz Gafka is a Software Architect and author of the Ecotone Framework. He writes about event sourcing, CQRS, and PHP architecture patterns.