Skip to content

feat: support closures in attributes and closure expressions (Enterprise)#678

Merged
dgafka merged 12 commits into
mainfrom
closure-in-attributes
Jul 3, 2026
Merged

feat: support closures in attributes and closure expressions (Enterprise)#678
dgafka merged 12 commits into
mainfrom
closure-in-attributes

Conversation

@dgafka

@dgafka dgafka commented Jul 2, 2026

Copy link
Copy Markdown
Member

Why is this change proposed?

PHP 8.5 allows closures inside attributes, but using one in any Ecotone attribute crashed container compilation with Serialization of 'Closure' is not allowed (fixes #649). This PR fixes that, and builds on it to let every expression property accept a Closure as a type-safe, refactorable, IDE-friendly alternative to Symfony expression strings (Ecotone Enterprise).

Description of Changes

  • Fix (Support of closures in Attributes #649): AttributeDefinition now carries an AttributeDeclaration (declaring class/method/parameter). When an attribute cannot be serialized, the container re-reads it via reflection at runtime — works with both the in-memory and the dumped/cached container.
  • Enterprise feature: every expression property accepts string|Closure: #[Payload], #[Header], #[Reference], #[Fetch], #[AddHeader], #[Delayed], #[TimeToLive], #[Deduplicated], #[DbalParameter], #[WithTenantResolver].
  • New ClosureExpressionEvaluator executes the closure like a message handler — closure parameters resolve via #[Payload] (with conversion), #[Header], #[Headers], #[Reference], #[ConfigurationVariable], Message type-hints, and first-parameter-defaults-to-payload. DbalParameter closures map parameters by name to the same context string expressions see.
  • Without an Enterprise licence, using a closure expression throws LicensingException (covered by tests for each path).

Usage examples

#[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 {}
#[Delayed(expression: static function (#[Payload] NotifyCustomer $command): int {
    return $command->delayInMilliseconds;
})]
#[Asynchronous('async')]
#[CommandHandler('customer.notify', endpointId: 'customerNotifyEndpoint')]
public function notify(NotifyCustomer $command): void {}
#[DbalWrite('INSERT INTO persons (person_id, name) VALUES (:personId, :fullName)')]
#[DbalParameter('fullName', expression: static function (string $firstName, string $lastName): string {
    return $firstName . ' ' . $lastName;
})]
public function insert(int $personId, string $firstName, string $lastName): void;

Use cases

  1. Complex header/payload derivation with full IDE support and static analysis instead of expression strings.
  2. Dynamic message delay/TTL computed from the command with real type safety.
  3. Deduplication keys and tenant resolution derived from message data without learning the expression language.

Flow

sequenceDiagram
    participant Compile as Container Compilation
    participant Dumped as Dumped Container
    participant Evaluator as ClosureExpressionEvaluator
    participant Handler

    Compile->>Compile: attribute contains Closure - cannot serialize
    Compile->>Dumped: store AttributeDeclaration (class/method/parameter)
    Note over Dumped: runtime boot
    Dumped->>Dumped: re-read attribute via reflection - live Closure
    Dumped->>Evaluator: evaluate(Closure, Message)
    Evaluator->>Evaluator: resolve closure parameters (Payload, Header, Reference, ...)
    Evaluator->>Handler: converted argument / enriched header
Loading

Pull Request Contribution Terms

  • I have read and agree to the contribution terms outlined in CONTRIBUTING.

dgafka added 12 commits July 2, 2026 18:24
…ise)

Fixes #649 by lazily re-reading attributes via reflection when they cannot
be serialized into the container. Adds Enterprise support for passing
Closures to every attribute expression property, executed with full
message-handler parameter resolution (#[Payload], #[Header], #[Reference], ...).
…d closures

ExpressionEvaluationService gains evaluateWithMessage() and evaluateWithContext(),
dispatching to Symfony expressions or ClosureExpressionEvaluator under the hood.
Additional context variables (value, service, business method parameters) bind
to closure parameters by name. All expression call sites now use the single
entrypoint, removing per-site closure branching and evaluator dependencies.
AttributeDeclaration::resolveClosure dumps a recipe recreating the Closure
from its attribute declaration, mirroring Symfony's Closure::fromCallable
approach. ClosureExpressionInvokerCompiler resolves all closure parameter
converters at container build time reusing standard converter builders, so
handler-parameter and Fetch closure expressions run without runtime
reflection. Runtime evaluator remains for interceptor-injected attributes,
DbalParameter context binding and nested closure expressions.
…olution

ClosureExpressionInvokerCompiler is now the only place deciding how closure
parameters resolve. The runtime ClosureExpressionEvaluator consumes the same
resolver definitions through a small definition interpreter instead of
duplicating the attribute-to-converter mapping, shrinking its dependencies
to LicenceDecider and the container. Nested closure expressions flow through
the shared plan via compileForRuntimeResolution, and Fetch with closure
expression on closure parameters fails fast with a clear configuration error.
VerifyEnterpriseLicenceForClosureExpressions compiler pass walks container
definitions and throws LicensingException during bootstrap when closure
expressions are used without Enterprise licence - detecting compiled closure
recipes, attribute definitions carrying closures and attribute references.
Runtime licence checks are removed from ClosureExpressionInvoker and
ClosureExpressionEvaluator, aligning with how other Enterprise features fail
fast at configuration time.
Nested closure expressions (closure expression attributes on closure
parameters) are disallowed with clear ConfigurationException, making
compilation infallible and removing the runtime-only resolution fallback.

ClosureExpressionEvaluator is removed entirely - every closure expression
now compiles into container definitions. Interceptor-injected attributes
(Delayed, AddHeader, TimeToLive, Deduplicated, WithTenantResolver) receive
pre-compiled ClosureExpressionInvoker through parameters marked with
InvokerFor attribute, resolved per intercepted endpoint at build time.
DbalParameter closures compile into ContextClosureExpressionInvoker bound
by business method parameter names. ExpressionEvaluationService returns to
string-only expressions, as closures never reach it anymore.
… expression program

AttributeExpressionExecutor replaces ClosureExpressionInvoker and the
attribute injection in interceptors - it carries the intercepted attribute
together with its compiled expression program (closure parameter resolvers
or Expression Language string) exposing execute(message). Interceptors
declare single ExecutorFor parameter per attribute instead of attribute
and invoker pairs, and string expressions in interceptors are now compiled
through the same executor, removing string-vs-closure branching at runtime.
AttributeDeclaration::resolveClosure recipe becomes obsolete, as executors
derive the closure from the attribute they carry.
@dgafka dgafka merged commit 4486fba into main Jul 3, 2026
10 of 11 checks passed
@dgafka dgafka deleted the closure-in-attributes branch July 3, 2026 06:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Support of closures in Attributes

1 participant