From a72f1c4715f34de251c485519f4f2be53f554125 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9gory=20Planchat?= Date: Wed, 23 Sep 2026 19:19:13 +0200 Subject: [PATCH 01/11] feat(workflow): the workflow method receives its stubs and its environment as arguments (#419) A parameter typed WorkflowEnvironment receives the environment, and an ActivityStub parameter marked #[Activities(Contract::class)] receives $env->activityStub(Contract::class). The loader reads the signature once, in load(), and a replay only runs that plan. Injected parameters are not input: the whole-input form run(array $input) now counts input parameters only, so an injected environment beside it keeps the whole input. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/Durable/Attribute/Activities.php | 29 ++++++ .../Workflow/WorkflowDefinitionLoader.php | 69 +++++++++---- .../Workflow/WorkflowMethodArgumentsTest.php | 96 +++++++++++++++++++ 3 files changed, 174 insertions(+), 20 deletions(-) create mode 100644 src/Durable/Attribute/Activities.php create mode 100644 tests/unit/Durable/Workflow/WorkflowMethodArgumentsTest.php diff --git a/src/Durable/Attribute/Activities.php b/src/Durable/Attribute/Activities.php new file mode 100644 index 000000000..4511b2c9d --- /dev/null +++ b/src/Durable/Attribute/Activities.php @@ -0,0 +1,29 @@ +activityStub(GreetingActivities::class)`. PHP has no runtime generics, so the attribute is + * what carries the contract; the `@param ActivityStub` docblock is only there + * for PHPStan. + */ +#[\Attribute(\Attribute::TARGET_PARAMETER)] +final class Activities +{ + /** + * @param class-string $contract + */ + public function __construct( + public readonly string $contract, + ) {} +} diff --git a/src/Durable/Workflow/WorkflowDefinitionLoader.php b/src/Durable/Workflow/WorkflowDefinitionLoader.php index 686fefc71..7b25c069b 100644 --- a/src/Durable/Workflow/WorkflowDefinitionLoader.php +++ b/src/Durable/Workflow/WorkflowDefinitionLoader.php @@ -4,6 +4,8 @@ namespace Gplanchat\Durable\Workflow; +use Gplanchat\Durable\Activity\ActivityStub; +use Gplanchat\Durable\Attribute\Activities; use Gplanchat\Durable\Attribute\AsQueryMethod; use Gplanchat\Durable\Attribute\AsSignalMethod; use Gplanchat\Durable\Attribute\AsUpdateMethod; @@ -71,15 +73,17 @@ public function load(string $workflowClass): array $reflection = new \ReflectionClass($workflowClass); $workflowType = $this->resolveWorkflowType($reflection); $method = $this->resolveWorkflowMethod($reflection); + // Read here, once: a replay only runs the plan. + $arguments = $this->planArguments($method); - $factory = function (array $input) use ($workflowClass, $method): callable { - return function (WorkflowEnvironment $env, ?QueryHandlerRegistry $queries = null) use ($workflowClass, $method, $input): mixed { + $factory = function (array $input) use ($workflowClass, $method, $arguments): callable { + return function (WorkflowEnvironment $env, ?QueryHandlerRegistry $queries = null) use ($workflowClass, $method, $arguments, $input): mixed { $instance = $this->instantiate($workflowClass, $env); $this->registerQueryHandlers($workflowClass, $instance, $queries ?? new QueryHandlerRegistry()); $this->registerSignalHandlers($workflowClass, $instance, $env); $this->registerUpdateHandlers($workflowClass, $instance, $env); - return $method->invokeArgs($instance, $this->mapInputToArguments($method, $input)); + return $method->invokeArgs($instance, array_map(static fn(\Closure $argument): mixed => $argument($env, $input), $arguments)); }; }; @@ -175,29 +179,54 @@ private function instantiate(string $workflowClass, WorkflowEnvironment $env): o } /** - * @param array $input + * Whether the loader supplies this parameter itself rather than reading it from the input: the + * environment, and the activity stubs. Every reader of a workflow method's signature asks this + * one question, so the input never counts a parameter the caller cannot pass. + */ + public static function isInjected(\ReflectionParameter $parameter): bool + { + $type = $parameter->getType(); + + return [] !== $parameter->getAttributes(Activities::class) + || ($type instanceof \ReflectionNamedType && \in_array($type->getName(), [WorkflowEnvironment::class, ActivityStub::class], true)); + } + + /** + * One closure per parameter, in order, that produces its argument from the environment and the + * input. * - * @return array + * @return list<\Closure(WorkflowEnvironment, array): mixed> */ - private function mapInputToArguments(\ReflectionMethod $method, array $input): array + private function planArguments(\ReflectionMethod $method): array { - $params = $method->getParameters(); - if (1 === \count($params)) { - $param = $params[0]; - if ($param->getType() instanceof \ReflectionNamedType - && 'array' === $param->getType()->getName() - && \in_array($param->getName(), ['input', 'payload'], true)) { - return [$input]; - } - } + $inputs = array_values(array_filter($method->getParameters(), static fn(\ReflectionParameter $p): bool => !self::isInjected($p))); + // `run(array $input)` receives the whole input; injected parameters beside it do not change that. + $wholeInput = 1 === \count($inputs) + && $inputs[0]->getType() instanceof \ReflectionNamedType + && 'array' === $inputs[0]->getType()->getName() + && \in_array($inputs[0]->getName(), ['input', 'payload'], true); - $args = []; - foreach ($params as $param) { - $key = $param->getName(); - $args[] = \array_key_exists($key, $input) ? $input[$key] : ($param->isDefaultValueAvailable() ? $param->getDefaultValue() : null); + $plan = []; + foreach ($method->getParameters() as $param) { + $type = $param->getType(); + $typeName = $type instanceof \ReflectionNamedType ? $type->getName() : (string) $type; + $attributes = $param->getAttributes(Activities::class); + + if ([] !== $attributes) { + $contract = $attributes[0]->newInstance()->contract; + $plan[] = static fn(WorkflowEnvironment $env): ActivityStub => $env->activityStub($contract); + } elseif (WorkflowEnvironment::class === $typeName) { + $plan[] = static fn(WorkflowEnvironment $env): WorkflowEnvironment => $env; + } elseif ($wholeInput) { + $plan[] = static fn(WorkflowEnvironment $env, array $input): array => $input; + } else { + $key = $param->getName(); + $default = $param->isDefaultValueAvailable() ? $param->getDefaultValue() : null; + $plan[] = static fn(WorkflowEnvironment $env, array $input): mixed => \array_key_exists($key, $input) ? $input[$key] : $default; + } } - return $args; + return $plan; } /** diff --git a/tests/unit/Durable/Workflow/WorkflowMethodArgumentsTest.php b/tests/unit/Durable/Workflow/WorkflowMethodArgumentsTest.php new file mode 100644 index 000000000..28511b460 --- /dev/null +++ b/tests/unit/Durable/Workflow/WorkflowMethodArgumentsTest.php @@ -0,0 +1,96 @@ + $greeting */ + #[AsWorkflowMethod] + public function run( + string $name, + #[Activities(SuiteActivities::class)] + ActivityStub $greeting, + WorkflowEnvironment $env, + ): string { + return $env->await($greeting->greet($name)); + } +} + +#[AsWorkflow('fan-out-by-argument')] +final class FanOutByArgumentWorkflow +{ + /** + * @param ActivityStub $first + * @param ActivityStub $second + * + * @return array + */ + #[AsWorkflowMethod] + public function run( + WorkflowEnvironment $env, + #[Activities(SuiteActivities::class)] + ActivityStub $first, + #[Activities(SuiteActivities::class)] + ActivityStub $second, + int $value, + ): array { + return $env->await($env->all($first->double($value), $second->double($value + 1))); + } +} + +#[AsWorkflow('whole-input')] +final class WholeInputWorkflow +{ + /** + * @param array $input + * + * @return array + */ + #[AsWorkflowMethod] + public function run(array $input, WorkflowEnvironment $env): array + { + return $input; + } +} + +/** + * The workflow method receives its stubs and its environment as arguments, the way a controller + * receives its services (#419). The input is still matched by name; injected parameters are not + * part of it. + */ +final class WorkflowMethodArgumentsTest extends TestCase +{ + public function testTheMethodReceivesItsStubAndItsEnvironmentWithoutAConstructor(): void + { + $env = WorkflowTestEnvironment::inMemory(['greet' => static fn(array $p): string => 'Hello, ' . $p['name'] . '!']); + + self::assertSame('Hello, Ada!', $env->runWorkflowClass(GreetByArgumentWorkflow::class, ['name' => 'Ada'])); + } + + public function testInjectedStubsComposeWithAll(): void + { + $env = WorkflowTestEnvironment::inMemory(['double' => static fn(array $p): int => $p['value'] * 2]); + + self::assertSame([4, 6], $env->runWorkflowClass(FanOutByArgumentWorkflow::class, ['value' => 2])); + } + + public function testAnInjectedEnvironmentDoesNotBreakTheWholeInputForm(): void + { + $env = WorkflowTestEnvironment::inMemory(); + + self::assertSame(['a' => 1, 'b' => 2], $env->runWorkflowClass(WholeInputWorkflow::class, ['a' => 1, 'b' => 2])); + } + +} From baf6d67be65471083f504ef855a39aa0e7c9e3ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9gory=20Planchat?= Date: Wed, 23 Sep 2026 19:19:27 +0200 Subject: [PATCH 02/11] feat(workflow): a stub parameter that cannot be resolved fails at registration (#419) load() refuses, with the class, method and parameter in the message: - an ActivityStub parameter without #[Activities], - #[Activities] on a parameter not typed ActivityStub, - a contract that does not exist or declares no #[AsActivityMethod]. #[AsActivity] is optional on a contract, so it is not the criterion. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../Workflow/WorkflowDefinitionLoader.php | 26 +++++++++- .../Workflow/WorkflowMethodArgumentsTest.php | 47 +++++++++++++++++++ 2 files changed, 71 insertions(+), 2 deletions(-) diff --git a/src/Durable/Workflow/WorkflowDefinitionLoader.php b/src/Durable/Workflow/WorkflowDefinitionLoader.php index 7b25c069b..7cda59a11 100644 --- a/src/Durable/Workflow/WorkflowDefinitionLoader.php +++ b/src/Durable/Workflow/WorkflowDefinitionLoader.php @@ -6,6 +6,7 @@ use Gplanchat\Durable\Activity\ActivityStub; use Gplanchat\Durable\Attribute\Activities; +use Gplanchat\Durable\Attribute\AsActivityMethod; use Gplanchat\Durable\Attribute\AsQueryMethod; use Gplanchat\Durable\Attribute\AsSignalMethod; use Gplanchat\Durable\Attribute\AsUpdateMethod; @@ -73,7 +74,7 @@ public function load(string $workflowClass): array $reflection = new \ReflectionClass($workflowClass); $workflowType = $this->resolveWorkflowType($reflection); $method = $this->resolveWorkflowMethod($reflection); - // Read here, once: a replay only runs the plan. + // Read and checked here, once: a replay only runs the plan. $arguments = $this->planArguments($method); $factory = function (array $input) use ($workflowClass, $method, $arguments): callable { @@ -193,7 +194,7 @@ public static function isInjected(\ReflectionParameter $parameter): bool /** * One closure per parameter, in order, that produces its argument from the environment and the - * input. + * input. Throws on a stub whose contract cannot be resolved, so the error comes at registration. * * @return list<\Closure(WorkflowEnvironment, array): mixed> */ @@ -211,10 +212,17 @@ private function planArguments(\ReflectionMethod $method): array $type = $param->getType(); $typeName = $type instanceof \ReflectionNamedType ? $type->getName() : (string) $type; $attributes = $param->getAttributes(Activities::class); + $where = \sprintf('%s::%s() parameter $%s', $method->getDeclaringClass()->getName(), $method->getName(), $param->getName()); if ([] !== $attributes) { + if (ActivityStub::class !== $typeName) { + throw new \InvalidArgumentException(\sprintf('%s carries #[Activities] but is typed %s, expected ActivityStub.', $where, $typeName)); + } $contract = $attributes[0]->newInstance()->contract; + self::assertActivityContract($contract); $plan[] = static fn(WorkflowEnvironment $env): ActivityStub => $env->activityStub($contract); + } elseif (ActivityStub::class === $typeName) { + throw new \InvalidArgumentException(\sprintf('%s is an ActivityStub without #[Activities(Contract::class)]: the loader cannot tell which contract to stub.', $where)); } elseif (WorkflowEnvironment::class === $typeName) { $plan[] = static fn(WorkflowEnvironment $env): WorkflowEnvironment => $env; } elseif ($wholeInput) { @@ -229,6 +237,20 @@ private function planArguments(\ReflectionMethod $method): array return $plan; } + private static function assertActivityContract(string $contract): void + { + if (!interface_exists($contract) && !class_exists($contract)) { + throw new \InvalidArgumentException(\sprintf('#[Activities(%s)] names no class or interface.', $contract)); + } + foreach ((new \ReflectionClass($contract))->getMethods() as $method) { + if ([] !== $method->getAttributes(AsActivityMethod::class)) { + return; + } + } + + throw new \InvalidArgumentException(\sprintf('%s declares no #[AsActivityMethod]: it is not an activity contract.', $contract)); + } + /** * Scans the workflow class for #[AsSignalMethod] attributes and registers them on WorkflowEnvironment. * diff --git a/tests/unit/Durable/Workflow/WorkflowMethodArgumentsTest.php b/tests/unit/Durable/Workflow/WorkflowMethodArgumentsTest.php index 28511b460..7a41002bc 100644 --- a/tests/unit/Durable/Workflow/WorkflowMethodArgumentsTest.php +++ b/tests/unit/Durable/Workflow/WorkflowMethodArgumentsTest.php @@ -9,6 +9,7 @@ use Gplanchat\Durable\Attribute\AsWorkflow; use Gplanchat\Durable\Attribute\AsWorkflowMethod; use Gplanchat\Durable\Testing\WorkflowTestEnvironment; +use Gplanchat\Durable\Workflow\WorkflowDefinitionLoader; use Gplanchat\Durable\WorkflowEnvironment; use PHPUnit\Framework\TestCase; use unit\Durable\Fixtures\SuiteActivities; @@ -65,6 +66,32 @@ public function run(array $input, WorkflowEnvironment $env): array } } +#[AsWorkflow('stub-without-contract')] +final class StubWithoutContractWorkflow +{ + #[AsWorkflowMethod] + public function run(ActivityStub $greeting): void {} +} + +#[AsWorkflow('contract-on-a-string')] +final class ContractOnAStringWorkflow +{ + #[AsWorkflowMethod] + public function run(#[Activities(SuiteActivities::class)] string $greeting): void {} +} + +interface NotAnActivityContract +{ + public function greet(string $name): string; +} + +#[AsWorkflow('not-a-contract')] +final class NotAContractWorkflow +{ + #[AsWorkflowMethod] + public function run(#[Activities(NotAnActivityContract::class)] ActivityStub $greeting): void {} +} + /** * The workflow method receives its stubs and its environment as arguments, the way a controller * receives its services (#419). The input is still matched by name; injected parameters are not @@ -93,4 +120,24 @@ public function testAnInjectedEnvironmentDoesNotBreakTheWholeInputForm(): void self::assertSame(['a' => 1, 'b' => 2], $env->runWorkflowClass(WholeInputWorkflow::class, ['a' => 1, 'b' => 2])); } + public function testAStubWithoutItsContractFailsAtRegistration(): void + { + $this->expectExceptionMessage('StubWithoutContractWorkflow::run() parameter $greeting is an ActivityStub without #[Activities(Contract::class)]'); + + (new WorkflowDefinitionLoader())->load(StubWithoutContractWorkflow::class); + } + + public function testAContractOnAParameterThatIsNotAStubFailsAtRegistration(): void + { + $this->expectExceptionMessage('ContractOnAStringWorkflow::run() parameter $greeting carries #[Activities] but is typed string, expected ActivityStub'); + + (new WorkflowDefinitionLoader())->load(ContractOnAStringWorkflow::class); + } + + public function testATypeWithNoActivityMethodIsNotAContract(): void + { + $this->expectExceptionMessage(NotAnActivityContract::class . ' declares no #[AsActivityMethod]'); + + (new WorkflowDefinitionLoader())->load(NotAContractWorkflow::class); + } } From 08e51bedf6b16e4ae7640d7c281d794b498eb31c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9gory=20Planchat?= Date: Wed, 23 Sep 2026 19:22:23 +0200 Subject: [PATCH 03/11] feat(workflow): injected parameters are not input, for every reader of the signature (#419) WorkflowDefinitionLoader::inputParameters() is the one filter: - workflowMethodParameters() no longer lists injected parameters, so the Nexus payload names stay the input names; - ChildWorkflowStub maps a parent's arguments onto the input parameters only, through a new optional parameter list on StubArguments::toPayload(); - the PHPStan extension exposes the same list on ChildWorkflowStub, so $child->run('Ada') is not reported as missing arguments. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/Durable/Stub/StubArguments.php | 8 +++++-- src/Durable/Workflow/ChildWorkflowStub.php | 3 ++- .../Workflow/WorkflowDefinitionLoader.php | 14 +++++++++-- .../Reflection/SchedulingMethodReflection.php | 23 ++++++++++++++++++- .../Workflow/WorkflowMethodArgumentsTest.php | 23 +++++++++++++++++++ .../DurablePhpstan/Fixtures/StubCallSites.php | 14 +++++++++++ .../StubMethodsExtensionTest.php | 9 ++++++++ 7 files changed, 88 insertions(+), 6 deletions(-) diff --git a/src/Durable/Stub/StubArguments.php b/src/Durable/Stub/StubArguments.php index 463edc537..cc465c8db 100644 --- a/src/Durable/Stub/StubArguments.php +++ b/src/Durable/Stub/StubArguments.php @@ -39,18 +39,22 @@ private function __construct() {} * @param array $arguments as `__call` received them: the positional ones * under indices, the named ones under their name * + * @param list<\ReflectionParameter>|null $parameters the parameters a caller passes, when some of + * `$method`'s are supplied by something else; all of + * them by default + * * @return array the named payload, one contract parameter per key * * @throws \BadMethodCallException if a named argument matches no parameter, if a required * parameter is not supplied, or if a parameter is served both * positionally and by name */ - public static function toPayload(\ReflectionFunctionAbstract $method, array $arguments): array + public static function toPayload(\ReflectionFunctionAbstract $method, array $arguments, ?array $parameters = null): array { $payload = []; $known = []; - foreach ($method->getParameters() as $i => $param) { + foreach ($parameters ?? $method->getParameters() as $i => $param) { $name = $param->getName(); $known[$name] = true; diff --git a/src/Durable/Workflow/ChildWorkflowStub.php b/src/Durable/Workflow/ChildWorkflowStub.php index dd7ae3003..9a93f3699 100644 --- a/src/Durable/Workflow/ChildWorkflowStub.php +++ b/src/Durable/Workflow/ChildWorkflowStub.php @@ -60,6 +60,7 @@ public function __call(string $name, array $arguments): \Gplanchat\Durable\Await */ private function argumentsToInput(array $arguments): array { - return StubArguments::toPayload($this->workflowMethod, $arguments); + // A parent passes the input only: the child's injected parameters are the loader's to supply. + return StubArguments::toPayload($this->workflowMethod, $arguments, WorkflowDefinitionLoader::inputParameters($this->workflowMethod)); } } diff --git a/src/Durable/Workflow/WorkflowDefinitionLoader.php b/src/Durable/Workflow/WorkflowDefinitionLoader.php index 7cda59a11..991b8be5d 100644 --- a/src/Durable/Workflow/WorkflowDefinitionLoader.php +++ b/src/Durable/Workflow/WorkflowDefinitionLoader.php @@ -121,7 +121,7 @@ private function resolveWorkflowType(\ReflectionClass $reflection): string public function workflowMethodParameters(string $workflowClass): array { $parameters = []; - foreach ($this->resolveWorkflowMethod(new \ReflectionClass($workflowClass))->getParameters() as $parameter) { + foreach (self::inputParameters($this->resolveWorkflowMethod(new \ReflectionClass($workflowClass))) as $parameter) { $parameters[$parameter->getName()] = $parameter->isDefaultValueAvailable(); } @@ -192,6 +192,16 @@ public static function isInjected(\ReflectionParameter $parameter): bool || ($type instanceof \ReflectionNamedType && \in_array($type->getName(), [WorkflowEnvironment::class, ActivityStub::class], true)); } + /** + * The workflow method's parameters the caller passes, in order: every parameter but the injected ones. + * + * @return list<\ReflectionParameter> + */ + public static function inputParameters(\ReflectionMethod $method): array + { + return array_values(array_filter($method->getParameters(), static fn(\ReflectionParameter $p): bool => !self::isInjected($p))); + } + /** * One closure per parameter, in order, that produces its argument from the environment and the * input. Throws on a stub whose contract cannot be resolved, so the error comes at registration. @@ -200,7 +210,7 @@ public static function isInjected(\ReflectionParameter $parameter): bool */ private function planArguments(\ReflectionMethod $method): array { - $inputs = array_values(array_filter($method->getParameters(), static fn(\ReflectionParameter $p): bool => !self::isInjected($p))); + $inputs = self::inputParameters($method); // `run(array $input)` receives the whole input; injected parameters beside it do not change that. $wholeInput = 1 === \count($inputs) && $inputs[0]->getType() instanceof \ReflectionNamedType diff --git a/src/DurablePhpstan/Reflection/SchedulingMethodReflection.php b/src/DurablePhpstan/Reflection/SchedulingMethodReflection.php index 44dbc1997..e8f142ef6 100644 --- a/src/DurablePhpstan/Reflection/SchedulingMethodReflection.php +++ b/src/DurablePhpstan/Reflection/SchedulingMethodReflection.php @@ -5,9 +5,11 @@ namespace Gplanchat\Durable\PHPStan\Reflection; use Gplanchat\Durable\Awaitable\Awaitable; +use Gplanchat\Durable\Workflow\WorkflowDefinitionLoader; use PHPStan\Reflection\ClassReflection; use PHPStan\Reflection\ExtendedFunctionVariant; use PHPStan\Reflection\ExtendedMethodReflection; +use PHPStan\Reflection\ExtendedParameterReflection; use PHPStan\Reflection\ExtendedParametersAcceptor; use PHPStan\Type\Generic\GenericObjectType; use PHPStan\Type\Type; @@ -53,7 +55,7 @@ private function wrap(ExtendedParametersAcceptor $variant): ExtendedParametersAc return new ExtendedFunctionVariant( $variant->getTemplateTypeMap(), $variant->getResolvedTemplateTypeMap(), - $variant->getParameters(), + $this->callerParameters($variant), $variant->isVariadic(), $this->awaitableOf($variant->getReturnType()), $this->awaitableOf($variant->getPhpDocReturnType()), @@ -62,6 +64,25 @@ private function wrap(ExtendedParametersAcceptor $variant): ExtendedParametersAc ); } + /** + * The parameters a caller passes. A workflow method may take its environment and its activity + * stubs as arguments; the loader supplies those, so a parent calling the child never does. + * + * @return list + */ + private function callerParameters(ExtendedParametersAcceptor $variant): array + { + $native = $this->contractMethod->getDeclaringClass()->getNativeReflection()->getMethod($this->contractMethod->getName()); + $injected = []; + foreach ($native->getParameters() as $parameter) { + if (WorkflowDefinitionLoader::isInjected($parameter)) { + $injected[$parameter->getName()] = true; + } + } + + return array_values(array_filter($variant->getParameters(), static fn(ExtendedParameterReflection $p): bool => !isset($injected[$p->getName()]))); + } + private function awaitableOf(Type $inner): Type { return new GenericObjectType(Awaitable::class, [$inner]); diff --git a/tests/unit/Durable/Workflow/WorkflowMethodArgumentsTest.php b/tests/unit/Durable/Workflow/WorkflowMethodArgumentsTest.php index 7a41002bc..f4f258033 100644 --- a/tests/unit/Durable/Workflow/WorkflowMethodArgumentsTest.php +++ b/tests/unit/Durable/Workflow/WorkflowMethodArgumentsTest.php @@ -92,6 +92,16 @@ final class NotAContractWorkflow public function run(#[Activities(NotAnActivityContract::class)] ActivityStub $greeting): void {} } +#[AsWorkflow('greets-through-a-child')] +final class GreetsThroughAChildWorkflow +{ + #[AsWorkflowMethod] + public function run(string $name, WorkflowEnvironment $env): string + { + return $env->await($env->childWorkflowStub(GreetByArgumentWorkflow::class)->run($name)); + } +} + /** * The workflow method receives its stubs and its environment as arguments, the way a controller * receives its services (#419). The input is still matched by name; injected parameters are not @@ -140,4 +150,17 @@ public function testATypeWithNoActivityMethodIsNotAContract(): void (new WorkflowDefinitionLoader())->load(NotAContractWorkflow::class); } + + public function testInjectedParametersAreNotPartOfTheInputNames(): void + { + self::assertSame(['name' => false], (new WorkflowDefinitionLoader())->workflowMethodParameters(GreetByArgumentWorkflow::class)); + } + + public function testAParentStartsAChildWithItsInputArgumentsOnly(): void + { + $env = WorkflowTestEnvironment::inMemory(['greet' => static fn(array $p): string => 'Hello, ' . $p['name'] . '!']); + $env->registerWorkflowClass(GreetByArgumentWorkflow::class); + + self::assertSame('Hello, Ada!', $env->runWorkflowClass(GreetsThroughAChildWorkflow::class, ['name' => 'Ada'])); + } } diff --git a/tests/unit/DurablePhpstan/Fixtures/StubCallSites.php b/tests/unit/DurablePhpstan/Fixtures/StubCallSites.php index 4a29bba54..d797f8d5a 100644 --- a/tests/unit/DurablePhpstan/Fixtures/StubCallSites.php +++ b/tests/unit/DurablePhpstan/Fixtures/StubCallSites.php @@ -61,6 +61,17 @@ public function run(string $text): string } } +/** Its method receives the environment as an argument: a parent passes `$text` only. */ +#[AsWorkflow(name: 'injecting-child')] +final class InjectingChildWorkflow +{ + #[AsWorkflowMethod] + public function run(string $text, WorkflowEnvironment $env): string + { + return $text; + } +} + #[AsWorkflow(name: 'call-sites')] final class StubCallSites { @@ -87,6 +98,9 @@ public function run(string $orderId): mixed // Correct: the child's entry method. $this->environment->await($this->child->run('bonjour')); + // Correct: the input argument only; the environment is the loader's to supply. + $this->environment->await($this->environment->childWorkflowStub(InjectingChildWorkflow::class)->run('bonjour')); + // WRONG — a typo. This is the case the extension exists for: without it, no analysis // error, and a BadMethodCallException at run time. $this->environment->await($this->orders->chrage($orderId, 100)); diff --git a/tests/unit/DurablePhpstan/StubMethodsExtensionTest.php b/tests/unit/DurablePhpstan/StubMethodsExtensionTest.php index 6a11162da..a1fe24a2c 100644 --- a/tests/unit/DurablePhpstan/StubMethodsExtensionTest.php +++ b/tests/unit/DurablePhpstan/StubMethodsExtensionTest.php @@ -106,6 +106,15 @@ public function testTheArgumentCountIsCheckedOnceTheMethodIsKnown(): void self::assertNotSame([], $this->matching($errors, 'invoked with 1 parameter, 2 required')); } + public function testAChildIsCalledWithoutTheParametersItsLoaderInjects(): void + { + $errors = $this->analyse(withExtension: true); + + // `run(string $text, WorkflowEnvironment $env)`: the parent passes `$text`, the loader + // supplies `$env`. Counting `$env` would report a correct call as one argument short. + self::assertSame([], $this->matching($errors, 'InjectingChildWorkflow::run() invoked with')); + } + public function testAReadonlyPropertyIsEnoughToCarryTheContract(): void { // The fixture declares its stubs `readonly` **without** a `@var` annotation. If this test From 2744f122ae3d8f42ed630e3bdee9816e69dd8a78 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9gory=20Planchat?= Date: Wed, 23 Sep 2026 19:23:17 +0200 Subject: [PATCH 04/11] feat(bundle): a workflow the loader refuses fails the container compilation (#419) WorkflowPass loads each tagged class once at compile time. The registry still loads them when it is built; the pass only moves the refusal from the first worker to the deploy. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../Compiler/WorkflowPass.php | 7 +++++ .../AsWorkflowAutoconfigurationTest.php | 20 +++++++++++++ .../WorkflowWithAnUnresolvableStub.php | 19 ++++++++++++ .../Fixtures/WorkflowWithArguments.php | 30 +++++++++++++++++++ 4 files changed, 76 insertions(+) create mode 100644 tests/unit/DurableBundle/Fixtures/WorkflowWithAnUnresolvableStub.php create mode 100644 tests/unit/DurableBundle/Fixtures/WorkflowWithArguments.php diff --git a/src/DurableBundle/DependencyInjection/Compiler/WorkflowPass.php b/src/DurableBundle/DependencyInjection/Compiler/WorkflowPass.php index 7691e6bdc..062061e74 100644 --- a/src/DurableBundle/DependencyInjection/Compiler/WorkflowPass.php +++ b/src/DurableBundle/DependencyInjection/Compiler/WorkflowPass.php @@ -4,12 +4,17 @@ namespace Gplanchat\Durable\Bundle\DependencyInjection\Compiler; +use Gplanchat\Durable\Workflow\WorkflowDefinitionLoader; use Gplanchat\Durable\WorkflowRegistry; use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface; use Symfony\Component\DependencyInjection\ContainerBuilder; /** * Registers the workflows tagged durable.workflow in the registry. + * + * The registry loads each class when it is built, at run time. The pass loads it once here too, so + * a workflow the loader refuses (an `ActivityStub` argument without its contract, for instance) + * fails the container compilation instead of the first worker that builds the registry. */ final class WorkflowPass implements CompilerPassInterface { @@ -20,6 +25,7 @@ public function process(ContainerBuilder $container): void } $registry = $container->findDefinition(WorkflowRegistry::class); + $loader = new WorkflowDefinitionLoader(); foreach ($container->findTaggedServiceIds('durable.workflow') as $id => $tags) { $definition = $container->getDefinition($id); @@ -27,6 +33,7 @@ public function process(ContainerBuilder $container): void if (!str_contains($class, '\\')) { continue; } + $loader->load($class); $registry->addMethodCall('registerClass', [$class]); } } diff --git a/tests/unit/DurableBundle/DependencyInjection/AsWorkflowAutoconfigurationTest.php b/tests/unit/DurableBundle/DependencyInjection/AsWorkflowAutoconfigurationTest.php index 164ac6ec6..db4bfeec2 100644 --- a/tests/unit/DurableBundle/DependencyInjection/AsWorkflowAutoconfigurationTest.php +++ b/tests/unit/DurableBundle/DependencyInjection/AsWorkflowAutoconfigurationTest.php @@ -10,6 +10,8 @@ use PHPUnit\Framework\TestCase; use Symfony\Component\DependencyInjection\ContainerBuilder; use unit\DurableBundle\Fixtures\NotAWorkflow; +use unit\DurableBundle\Fixtures\WorkflowWithAnUnresolvableStub; +use unit\DurableBundle\Fixtures\WorkflowWithArguments; use unit\DurableBundle\Fixtures\WorkflowWithEnvironment; use unit\DurableBundle\Fixtures\WorkflowWithoutDependencies; @@ -47,6 +49,24 @@ public function testAWorkflowReceivingTheEnvironmentStillCompiles(): void self::assertContains(WorkflowWithEnvironment::class, $this->registeredClasses($container)); } + public function testAWorkflowTakingItsStubsAsArgumentsCompiles(): void + { + $container = $this->compileWith([WorkflowWithArguments::class]); + + self::assertContains(WorkflowWithArguments::class, $this->registeredClasses($container)); + } + + /** + * The registry loads its classes when it is built, at run time. Validating in the pass moves a + * stub the loader cannot build to the container compilation, where a deploy still stops. + */ + public function testAWorkflowWithAnUnresolvableStubFailsTheCompilation(): void + { + $this->expectExceptionMessage('WorkflowWithAnUnresolvableStub::run() parameter $greeting is an ActivityStub without #[Activities(Contract::class)]'); + + $this->compileWith([WorkflowWithAnUnresolvableStub::class]); + } + public function testAClassWithoutTheAttributeDoesNotReachTheRegistry(): void { $container = $this->compileWith([NotAWorkflow::class]); diff --git a/tests/unit/DurableBundle/Fixtures/WorkflowWithAnUnresolvableStub.php b/tests/unit/DurableBundle/Fixtures/WorkflowWithAnUnresolvableStub.php new file mode 100644 index 000000000..794d95e98 --- /dev/null +++ b/tests/unit/DurableBundle/Fixtures/WorkflowWithAnUnresolvableStub.php @@ -0,0 +1,19 @@ + $greeting */ + #[AsWorkflowMethod] + public function run( + string $name, + #[Activities(SuiteActivities::class)] + ActivityStub $greeting, + WorkflowEnvironment $env, + ): string { + return $env->await($greeting->greet($name)); + } +} From bbc8eb2debe6a35fd08b4703cb67d49798637a91 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9gory=20Planchat?= Date: Wed, 23 Sep 2026 19:26:39 +0200 Subject: [PATCH 05/11] feat(phpstan): #[Activities] and its @param ActivityStub cannot name two contracts (#419) The loader reads the attribute, PHPStan reads the docblock: no extension point types a parameter from an attribute. ActivitiesParameterRule reports a parameter whose two statements disagree (durable.activities.contractMismatch), registered in extension.neon. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../Rules/ActivitiesParameterRule.php | 86 +++++++++++++++++++ src/DurablePhpstan/extension.neon | 3 + .../ActivitiesParameterRuleTest.php | 66 ++++++++++++++ .../Fixtures/ActivitiesParameters.php | 41 +++++++++ 4 files changed, 196 insertions(+) create mode 100644 src/DurablePhpstan/Rules/ActivitiesParameterRule.php create mode 100644 tests/unit/DurablePhpstan/ActivitiesParameterRuleTest.php create mode 100644 tests/unit/DurablePhpstan/Fixtures/ActivitiesParameters.php diff --git a/src/DurablePhpstan/Rules/ActivitiesParameterRule.php b/src/DurablePhpstan/Rules/ActivitiesParameterRule.php new file mode 100644 index 000000000..8403e94de --- /dev/null +++ b/src/DurablePhpstan/Rules/ActivitiesParameterRule.php @@ -0,0 +1,86 @@ +` saying the same thing. + * + * The attribute is what the loader reads at run time; the docblock is what PHPStan reads, because + * no extension point types a parameter from an attribute. Two statements of one fact can drift + * apart, and a drifted docblock would have PHPStan check the calls against the wrong contract. + * + * @implements Rule + */ +final class ActivitiesParameterRule implements Rule +{ + public function getNodeType(): string + { + return InClassMethodNode::class; + } + + /** + * @return list + */ + public function processNode(Node $node, Scope $scope): array + { + $phpDocTypes = []; + foreach ($node->getMethodReflection()->getVariants()[0]->getParameters() as $parameter) { + $phpDocTypes[$parameter->getName()] = $parameter->getPhpDocType(); + } + + $errors = []; + foreach ($node->getOriginalNode()->params as $param) { + $contract = self::contractOf($param); + if (null === $contract || !$param->var instanceof Node\Expr\Variable || !\is_string($param->var->name)) { + continue; + } + $name = $param->var->name; + $phpDoc = $phpDocTypes[$name] ?? null; + // A bare `ActivityStub` answers its bound, `object`, which names no class. + $declared = null === $phpDoc ? [] : $phpDoc->getTemplateType(ActivityStub::class, 'TActivity')->getObjectClassNames(); + + if ([] === $declared) { + continue; + } + + if ([strtolower($contract)] !== array_map(strtolower(...), $declared)) { + $errors[] = RuleErrorBuilder::message(\sprintf('Parameter $%s is #[Activities(%s::class)] but its @param says %s: the loader and PHPStan see two contracts.', $name, $contract, 'ActivityStub<' . implode('|', $declared) . '>')) + ->identifier('durable.activities.contractMismatch') + ->line($param->getStartLine()) + ->build(); + } + } + + return $errors; + } + + private static function contractOf(Node\Param $param): ?string + { + foreach ($param->attrGroups as $group) { + foreach ($group->attrs as $attribute) { + if (Activities::class !== $attribute->name->toString()) { + continue; + } + $value = $attribute->args[0]->value ?? null; + if ($value instanceof ClassConstFetch && $value->class instanceof Name) { + return $value->class->toString(); + } + } + } + + return null; + } +} diff --git a/src/DurablePhpstan/extension.neon b/src/DurablePhpstan/extension.neon index c85d9e4e0..a35dd851f 100644 --- a/src/DurablePhpstan/extension.neon +++ b/src/DurablePhpstan/extension.neon @@ -10,3 +10,6 @@ services: class: Gplanchat\Durable\PHPStan\Reflection\StubMethodsExtension tags: - phpstan.broker.methodsClassReflectionExtension + +rules: + - Gplanchat\Durable\PHPStan\Rules\ActivitiesParameterRule diff --git a/tests/unit/DurablePhpstan/ActivitiesParameterRuleTest.php b/tests/unit/DurablePhpstan/ActivitiesParameterRuleTest.php new file mode 100644 index 000000000..ab24c0025 --- /dev/null +++ b/tests/unit/DurablePhpstan/ActivitiesParameterRuleTest.php @@ -0,0 +1,66 @@ +` tells PHPStan. + * Two statements of one fact can disagree, and the rule is what keeps them from doing so. + * + * Checked as {@see StubMethodsExtensionTest} is: by running PHPStan over a fixture. + */ +final class ActivitiesParameterRuleTest extends TestCase +{ + private const FIXTURE = __DIR__ . '/Fixtures/ActivitiesParameters.php'; + + public function testAnAgreeingDocblockIsNotReported(): void + { + self::assertSame([], $this->matching($this->analyse(), '$agreeing')); + } + + public function testADisagreeingDocblockIsReported(): void + { + self::assertNotSame([], $this->matching($this->analyse(), 'Parameter $disagreeing is #[Activities(unit\DurablePhpstan\Fixtures\OrderActivities::class)] but its @param says ActivityStub')); + } + + /** + * @return list + */ + private function analyse(): array + { + $root = \dirname(__DIR__, 3); + $config = tempnam(sys_get_temp_dir(), 'durable-phpstan-') . '.neon'; + file_put_contents($config, 'includes:' . "\n - " . $root . "/src/DurablePhpstan/extension.neon\n" + . "parameters:\n level: 5\n paths:\n - " . self::FIXTURE . "\n - " . __DIR__ . "/Fixtures/StubCallSites.php\n"); + + $command = array_map(escapeshellarg(...), [$root . '/vendor/bin/phpstan', 'analyse', '--no-progress', '--error-format=json', '-c', $config]); + $out = (string) shell_exec(implode(' ', $command) . ' 2>/dev/null'); + unlink($config); + + /** @var array{files?: array}>} $decoded */ + $decoded = json_decode($out, true) ?: []; + self::assertArrayHasKey('files', $decoded, 'PHPStan returned nothing usable'); + + $messages = []; + foreach ($decoded['files'] as $file) { + foreach ($file['messages'] as $message) { + $messages[] = $message['message']; + } + } + + return $messages; + } + + /** + * @param list $errors + * + * @return list + */ + private function matching(array $errors, string $needle): array + { + return array_values(array_filter($errors, static fn(string $m): bool => str_contains($m, $needle))); + } +} diff --git a/tests/unit/DurablePhpstan/Fixtures/ActivitiesParameters.php b/tests/unit/DurablePhpstan/Fixtures/ActivitiesParameters.php new file mode 100644 index 000000000..400227110 --- /dev/null +++ b/tests/unit/DurablePhpstan/Fixtures/ActivitiesParameters.php @@ -0,0 +1,41 @@ + $agreeing + * @param ActivityStub $disagreeing + */ + #[AsWorkflowMethod] + public function run( + string $orderId, + WorkflowEnvironment $env, + #[Activities(OrderActivities::class)] + ActivityStub $agreeing, + #[Activities(OrderActivities::class)] + ActivityStub $disagreeing, + ): mixed { + return $env->await($agreeing->charge($orderId, 100)); + } +} From a78f99dc5376941c739504b3f8489a4b18888c09 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9gory=20Planchat?= Date: Wed, 23 Sep 2026 19:27:05 +0200 Subject: [PATCH 06/11] feat(phpstan): an #[Activities] parameter without its generic is reported once, where it is fixed (#419) Without @param ActivityStub, every call on the stub is already "undefined method". The rule reports the cause on the parameter, with the line to add as a tip. PHPStan has no non-failing level, so it is an error with its own identifier (durable.activities.missingGeneric) that a project can ignore. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/DurablePhpstan/Rules/ActivitiesParameterRule.php | 12 ++++++++++++ .../DurablePhpstan/ActivitiesParameterRuleTest.php | 7 +++++++ .../DurablePhpstan/Fixtures/ActivitiesParameters.php | 2 ++ 3 files changed, 21 insertions(+) diff --git a/src/DurablePhpstan/Rules/ActivitiesParameterRule.php b/src/DurablePhpstan/Rules/ActivitiesParameterRule.php index 8403e94de..1f570b686 100644 --- a/src/DurablePhpstan/Rules/ActivitiesParameterRule.php +++ b/src/DurablePhpstan/Rules/ActivitiesParameterRule.php @@ -22,6 +22,11 @@ * no extension point types a parameter from an attribute. Two statements of one fact can drift * apart, and a drifted docblock would have PHPStan check the calls against the wrong contract. * + * A missing docblock is reported too. Without the generic, {@see \Gplanchat\Durable\PHPStan\Reflection\StubMethodsExtension} + * cannot resolve the contract and every call on the stub is already "undefined method"; this rule + * reports the cause once, on the parameter, with the line to write. PHPStan has no non-failing + * level, so it is an error with its own identifier, which a project can ignore. + * * @implements Rule */ final class ActivitiesParameterRule implements Rule @@ -53,6 +58,13 @@ public function processNode(Node $node, Scope $scope): array $declared = null === $phpDoc ? [] : $phpDoc->getTemplateType(ActivityStub::class, 'TActivity')->getObjectClassNames(); if ([] === $declared) { + $short = substr($contract, (int) strrpos($contract, '\\') + 1); + $errors[] = RuleErrorBuilder::message(\sprintf('Parameter $%s is #[Activities(%s::class)] but has no @param ActivityStub<%s>: PHPStan cannot check the calls on it.', $name, $contract, $short)) + ->identifier('durable.activities.missingGeneric') + ->tip(\sprintf('Add /** @param ActivityStub<%s> $%s */ to the method.', $short, $name)) + ->line($param->getStartLine()) + ->build(); + continue; } diff --git a/tests/unit/DurablePhpstan/ActivitiesParameterRuleTest.php b/tests/unit/DurablePhpstan/ActivitiesParameterRuleTest.php index ab24c0025..2abcdd006 100644 --- a/tests/unit/DurablePhpstan/ActivitiesParameterRuleTest.php +++ b/tests/unit/DurablePhpstan/ActivitiesParameterRuleTest.php @@ -26,6 +26,13 @@ public function testADisagreeingDocblockIsReported(): void self::assertNotSame([], $this->matching($this->analyse(), 'Parameter $disagreeing is #[Activities(unit\DurablePhpstan\Fixtures\OrderActivities::class)] but its @param says ActivityStub')); } + public function testAMissingDocblockIsReportedWithItsFix(): void + { + // Without the generic, every call on the stub is already "undefined method". The rule + // reports the cause once, where it can be fixed. + self::assertNotSame([], $this->matching($this->analyse(), 'Parameter $undocumented is #[Activities(unit\DurablePhpstan\Fixtures\OrderActivities::class)] but has no @param ActivityStub')); + } + /** * @return list */ diff --git a/tests/unit/DurablePhpstan/Fixtures/ActivitiesParameters.php b/tests/unit/DurablePhpstan/Fixtures/ActivitiesParameters.php index 400227110..a43f9e36e 100644 --- a/tests/unit/DurablePhpstan/Fixtures/ActivitiesParameters.php +++ b/tests/unit/DurablePhpstan/Fixtures/ActivitiesParameters.php @@ -35,6 +35,8 @@ public function run( ActivityStub $agreeing, #[Activities(OrderActivities::class)] ActivityStub $disagreeing, + #[Activities(OrderActivities::class)] + ActivityStub $undocumented, ): mixed { return $env->await($agreeing->charge($orderId, 100)); } From 823778df157e9bc792f8e131c03490e3480aa2af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9gory=20Planchat?= Date: Wed, 23 Sep 2026 19:28:28 +0200 Subject: [PATCH 07/11] docs: the workflow method receives its stubs and its environment as arguments (#419) The getting-started guide shows the argument form; the workflows page explains what Durable supplies, what stays input, the PHPStan docblock, why ActivityOptions still go through activityStub(), and why the constructor form remains the one for classes implementing a contract interface. The durable-phpstan README documents the two identifiers. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../user/getting-started/_index.fr.md | 21 ++++++---- documentation/user/getting-started/_index.md | 21 ++++++---- documentation/user/workflows/_index.fr.md | 41 ++++++++++++++++++- documentation/user/workflows/_index.md | 40 +++++++++++++++++- src/DurablePhpstan/README.md | 26 ++++++++++++ 5 files changed, 133 insertions(+), 16 deletions(-) diff --git a/documentation/user/getting-started/_index.fr.md b/documentation/user/getting-started/_index.fr.md index 56e26b8e9..6bf9690ce 100644 --- a/documentation/user/getting-started/_index.fr.md +++ b/documentation/user/getting-started/_index.fr.md @@ -232,6 +232,8 @@ declare(strict_types=1); namespace App\Workflow; use App\Workflow\Activity\GreetingActivities; +use Gplanchat\Durable\Activity\ActivityStub; +use Gplanchat\Durable\Attribute\Activities; use Gplanchat\Durable\Attribute\AsWorkflow; use Gplanchat\Durable\Attribute\AsWorkflowMethod; use Gplanchat\Durable\WorkflowEnvironment; @@ -239,18 +241,23 @@ use Gplanchat\Durable\WorkflowEnvironment; #[AsWorkflow(name: 'greet')] final class GreetWorkflow { - public function __construct(private readonly WorkflowEnvironment $environment) {} - + /** @param ActivityStub $greeting */ #[AsWorkflowMethod] - public function run(string $name): string - { - $activities = $this->environment->activityStub(GreetingActivities::class); - - return $this->environment->await($activities->greet($name)); + public function run( + string $name, + #[Activities(GreetingActivities::class)] + ActivityStub $greeting, + WorkflowEnvironment $env, + ): string { + return $env->await($greeting->greet($name)); } } ``` +`$name` vient de l'entrée avec laquelle le workflow démarre. `$greeting` et `$env`, non : Durable +les fournit, comme Symfony fournit ses services à un contrôleur. Voir +[Les arguments que fournit Durable](../workflows/#les-arguments-que-fournit-durable). + ### 4. Le déclencher depuis un contrôleur ou un service {#4--le-déclencher-depuis-un-contrôleur-ou-un-service} ```php diff --git a/documentation/user/getting-started/_index.md b/documentation/user/getting-started/_index.md index 03dcf2c31..4cdbd403b 100644 --- a/documentation/user/getting-started/_index.md +++ b/documentation/user/getting-started/_index.md @@ -232,6 +232,8 @@ declare(strict_types=1); namespace App\Workflow; use App\Workflow\Activity\GreetingActivities; +use Gplanchat\Durable\Activity\ActivityStub; +use Gplanchat\Durable\Attribute\Activities; use Gplanchat\Durable\Attribute\AsWorkflow; use Gplanchat\Durable\Attribute\AsWorkflowMethod; use Gplanchat\Durable\WorkflowEnvironment; @@ -239,18 +241,23 @@ use Gplanchat\Durable\WorkflowEnvironment; #[AsWorkflow(name: 'greet')] final class GreetWorkflow { - public function __construct(private readonly WorkflowEnvironment $environment) {} - + /** @param ActivityStub $greeting */ #[AsWorkflowMethod] - public function run(string $name): string - { - $activities = $this->environment->activityStub(GreetingActivities::class); - - return $this->environment->await($activities->greet($name)); + public function run( + string $name, + #[Activities(GreetingActivities::class)] + ActivityStub $greeting, + WorkflowEnvironment $env, + ): string { + return $env->await($greeting->greet($name)); } } ``` +`$name` comes from the input the workflow is started with. `$greeting` and `$env` do not: Durable +supplies them, the way Symfony supplies a controller's services. See +[Arguments Durable supplies](../workflows/#arguments-durable-supplies). + ### 4. Dispatch from a controller or service {#4--dispatch-from-a-controller-or-service} ```php diff --git a/documentation/user/workflows/_index.fr.md b/documentation/user/workflows/_index.fr.md index 7f56a70b5..62cf053b8 100644 --- a/documentation/user/workflows/_index.fr.md +++ b/documentation/user/workflows/_index.fr.md @@ -224,6 +224,45 @@ Un message enregistré après le déclenchement de l'échéance n'est jamais app cette échéance a tranchée ; il reste disponible pour l'attente suivante, et son gestionnaire s'exécute à ce moment-là. Voir **DUR032** et **DUR035**. +### Les arguments que fournit Durable + +La méthode du workflow peut recevoir ses stubs d'activités et son environnement en arguments, au +lieu de les construire dans un constructeur : + +```php +/** @param ActivityStub $orders */ +#[AsWorkflowMethod] +public function run( + string $orderId, + #[Activities(OrderActivities::class)] + ActivityStub $orders, + WorkflowEnvironment $env, +): mixed { + return $env->await($orders->charge($orderId)); +} +``` + +- Un paramètre typé **`WorkflowEnvironment`** reçoit l'environnement. +- Un paramètre typé **`ActivityStub`** et marqué **`#[Activities(Contrat::class)]`** reçoit + `$env->activityStub(Contrat::class)`. PHP n'a pas de génériques à l'exécution : c'est l'attribut + qui nomme le contrat. +- Tout autre paramètre est une **entrée**, lue par son nom, comme avant. Personne ne passe les + paramètres fournis : ni le code qui démarre le workflow, ni un parent qui l'appelle comme enfant, + ni une opération Nexus. +- Le docblock **`@param ActivityStub`** sert à PHPStan. Avec + [`gplanchat/durable-phpstan`](../packages/), un docblock qui nomme un autre contrat que + l'attribut est une erreur, et son absence aussi, puisque PHPStan ne peut pas vérifier les appels + sans lui. +- Un stub qui a besoin d'**`ActivityOptions`** garde `$env->activityStub($contrat, $options)` : les + arguments d'un attribut ne savent pas construire une `Duration`. +- Les erreurs tombent à l'**enregistrement** du workflow (compilation du conteneur, avec le + bundle) : un `ActivityStub` sans `#[Activities]`, un `#[Activities]` sur un autre type, ou un + contrat qui ne déclare aucun `#[AsActivityMethod]`. + +La forme par constructeur reste valable. C'est celle qu'il faut quand la classe implémente une +interface de contrat comme `OrderWorkflowContract` plus haut : PHP n'autorise pas l'implémentation à +ajouter des paramètres obligatoires à `run()`. + ### `ActivityOptions` sur le stub Pour appliquer **réessais**, **délais**, **file de tâches** et métadonnées de planification voisines à tous les appels passant par un stub donné, passez des **`ActivityOptions`** en second argument d'**`activityStub()`** : @@ -292,7 +331,7 @@ ce qu'un workflow peut faire, et rien de ce que le moteur garde pour lui. | `some($count, ...$awaitables)` | Se résout quand `$count` membres ont **réussi**, indexés par position de déclaration. Les autres sont annulés. | | `timer($duration, $summary = '')` | Un awaitable qui se résout à l'échéance de la durée. Se compose comme n'importe quel autre. | | `sleep($duration, $summary = '')` | Attend, et fait l'attente pour vous. Dit ce qu'il fait. | -| `activityStub($contract, $options = null)` | Un proxy typé sur un contrat d'activité. Construisez-le dans le constructeur ; tous ses appels portent `$options`. | +| `activityStub($contract, $options = null)` | Un proxy typé sur un contrat d'activité. Construisez-le dans le constructeur, ou déclarez-le en [argument `#[Activities]`](#les-arguments-que-fournit-durable) s'il n'a pas besoin d'options ; tous ses appels portent `$options`. | | `childWorkflowStub($class, $options = null)` | Le même, pour un workflow enfant : résolu depuis la classe de l'enfant, et ses appels se composent comme les autres. | | `onSignal($name, $handler)` | Enregistre un gestionnaire de signal. Le gestionnaire mute l'état du workflow et `await()` l'observe ; il n'y a pas d'attente séparée. Le nom prend une énumération adossée, donc une faute de frappe est une erreur de type et non une attente qui ne se résout jamais. | | `onUpdate($name, $handler)` | Le même pour une mise à jour, dont la valeur de retour du gestionnaire est la réponse rendue à l'appelant. | diff --git a/documentation/user/workflows/_index.md b/documentation/user/workflows/_index.md index c797f264d..ca367abcf 100644 --- a/documentation/user/workflows/_index.md +++ b/documentation/user/workflows/_index.md @@ -218,6 +218,44 @@ reached, **including** when the awaited signal is delivered after the deadline e recorded after the deadline fired is never applied to the wait that deadline settled; it stays available to the next wait, and its handler runs then. See **DUR032** and **DUR035**. +### Arguments Durable supplies + +The workflow method can take its activity stubs and its environment as arguments instead of +building them in a constructor: + +```php +/** @param ActivityStub $orders */ +#[AsWorkflowMethod] +public function run( + string $orderId, + #[Activities(OrderActivities::class)] + ActivityStub $orders, + WorkflowEnvironment $env, +): mixed { + return $env->await($orders->charge($orderId)); +} +``` + +- A parameter typed **`WorkflowEnvironment`** receives the environment. +- A parameter typed **`ActivityStub`** and marked **`#[Activities(Contract::class)]`** receives + `$env->activityStub(Contract::class)`. PHP has no runtime generics, so the attribute is what names + the contract. +- Every other parameter is **input**, matched by name, as before. A caller never passes the + supplied ones: not the code that starts the workflow, not a parent calling it as a child, not a + Nexus operation. +- The **`@param ActivityStub`** docblock is for PHPStan. With + [`gplanchat/durable-phpstan`](../packages/), a docblock that names another contract than the + attribute is an error, and so is a missing one, since PHPStan cannot check the calls without it. +- A stub that needs **`ActivityOptions`** keeps `$env->activityStub($contract, $options)`: attribute + arguments cannot build a `Duration`. +- Mistakes fail when the workflow is **registered** (container compilation, with the bundle): an + `ActivityStub` without `#[Activities]`, `#[Activities]` on another type, or a contract that + declares no `#[AsActivityMethod]`. + +The constructor form keeps working. It is the one to use when the class implements a contract +interface such as `OrderWorkflowContract` above: PHP does not let the implementation add required +parameters to `run()`. + ### ActivityOptions on the stub To apply **retries**, **timeouts**, **task queue**, and related scheduling metadata to every call made through a given stub, pass **`ActivityOptions`** as the second argument to **`activityStub()`**: @@ -286,7 +324,7 @@ everything a workflow can do, and nothing the engine keeps for itself. | `some($count, ...$awaitables)` | Settles when `$count` members have **succeeded**, indexed by declaration position. The rest are cancelled. | | `timer($duration, $summary = '')` | An awaitable that settles when the duration elapses. Composes like any other. | | `sleep($duration, $summary = '')` | Waits, and awaits for you. Says what it does. | -| `activityStub($contract, $options = null)` | A typed proxy over an activity contract. Build it in the constructor; every call it makes carries `$options`. | +| `activityStub($contract, $options = null)` | A typed proxy over an activity contract. Build it in the constructor, or declare it as an [`#[Activities]` argument](#arguments-durable-supplies) when it needs no options; every call it makes carries `$options`. | | `childWorkflowStub($class, $options = null)` | The same, for a child workflow: resolved from the child's class, and its calls compose like any other. | | `onSignal($name, $handler)` | Registers a signal handler. The handler mutates workflow state and `await()` observes it; there is no separate wait. The name takes a backed enum, so a typo is a type error rather than a wait that never settles. | | `onUpdate($name, $handler)` | The same for an update, whose handler's return value is the caller's response. | diff --git a/src/DurablePhpstan/README.md b/src/DurablePhpstan/README.md index 9a623894e..c2a0a3e4b 100644 --- a/src/DurablePhpstan/README.md +++ b/src/DurablePhpstan/README.md @@ -88,6 +88,32 @@ private ActivityStub $orders; In both cases, if the contract stays untraceable, the call is simply **unknown** to PHPStan rather than accepted blindly: better a false positive than a check that has been silently switched off. +### A stub received as a workflow method argument + +`#[Activities(OrderActivities::class)]` tells Durable the contract at run time, but PHPStan cannot +read a type from an attribute. The `@param` docblock is what it reads: + +```php +/** @param ActivityStub $orders */ +#[AsWorkflowMethod] +public function run( + string $orderId, + #[Activities(OrderActivities::class)] + ActivityStub $orders, + WorkflowEnvironment $env, +): mixed +``` + +The extension's rule keeps the two in step: + +| Identifier | Reported when | +|---|---| +| `durable.activities.contractMismatch` | the docblock names another contract than the attribute | +| `durable.activities.missingGeneric` | the parameter has no `@param ActivityStub<…>`: every call on it would be unknown | + +A parent calling such a workflow as a child passes the input arguments only; the extension leaves +the supplied parameters out of the signature it checks. + ## What it does not do A method absent from the contract, or present but without `#[AsActivityMethod]` — respectively From bcb876c39a3bfee2bdfca669dd3eed64accefc66 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9gory=20Planchat?= Date: Wed, 23 Sep 2026 19:40:46 +0200 Subject: [PATCH 08/11] fix(workflow): an input default is built per execution, and two refusals are pinned (#419) Planning the arguments once had captured each input default at load(), so a `new` initializer was shared by every execution of a long-lived worker. The default is evaluated inside the plan again. Also pinned by tests: a contract that does not exist is refused at registration, and a Nexus-fulfilling workflow with injected parameters matches the operation on its input parameters only. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../Workflow/WorkflowDefinitionLoader.php | 6 +- .../Workflow/WorkflowMethodArgumentsTest.php | 55 +++++++++++++++++++ 2 files changed, 59 insertions(+), 2 deletions(-) diff --git a/src/Durable/Workflow/WorkflowDefinitionLoader.php b/src/Durable/Workflow/WorkflowDefinitionLoader.php index 991b8be5d..791427141 100644 --- a/src/Durable/Workflow/WorkflowDefinitionLoader.php +++ b/src/Durable/Workflow/WorkflowDefinitionLoader.php @@ -239,8 +239,10 @@ private function planArguments(\ReflectionMethod $method): array $plan[] = static fn(WorkflowEnvironment $env, array $input): array => $input; } else { $key = $param->getName(); - $default = $param->isDefaultValueAvailable() ? $param->getDefaultValue() : null; - $plan[] = static fn(WorkflowEnvironment $env, array $input): mixed => \array_key_exists($key, $input) ? $input[$key] : $default; + // The default is evaluated per execution: a `new` initializer must not be shared between runs. + $plan[] = static fn(WorkflowEnvironment $env, array $input): mixed => \array_key_exists($key, $input) + ? $input[$key] + : ($param->isDefaultValueAvailable() ? $param->getDefaultValue() : null); } } diff --git a/tests/unit/Durable/Workflow/WorkflowMethodArgumentsTest.php b/tests/unit/Durable/Workflow/WorkflowMethodArgumentsTest.php index f4f258033..a32c528ee 100644 --- a/tests/unit/Durable/Workflow/WorkflowMethodArgumentsTest.php +++ b/tests/unit/Durable/Workflow/WorkflowMethodArgumentsTest.php @@ -8,6 +8,7 @@ use Gplanchat\Durable\Attribute\Activities; use Gplanchat\Durable\Attribute\AsWorkflow; use Gplanchat\Durable\Attribute\AsWorkflowMethod; +use Gplanchat\Durable\Nexus\Serving\NexusFulfilmentParameterNames; use Gplanchat\Durable\Testing\WorkflowTestEnvironment; use Gplanchat\Durable\Workflow\WorkflowDefinitionLoader; use Gplanchat\Durable\WorkflowEnvironment; @@ -102,6 +103,36 @@ public function run(string $name, WorkflowEnvironment $env): string } } +interface GreetingOperation +{ + public function greet(string $name): string; +} + +#[AsWorkflow('names-a-missing-contract')] +final class NamesAMissingContractWorkflow +{ + #[AsWorkflowMethod] + public function run( + #[Activities('unit\\Gplanchat\\Durable\\Workflow\\NoSuchContract')] + ActivityStub $greeting, + ): void {} +} + +final class Tally +{ + public int $count = 0; +} + +#[AsWorkflow('counts-into-a-default')] +final class CountsIntoADefaultWorkflow +{ + #[AsWorkflowMethod] + public function run(Tally $tally = new Tally()): int + { + return ++$tally->count; + } +} + /** * The workflow method receives its stubs and its environment as arguments, the way a controller * receives its services (#419). The input is still matched by name; injected parameters are not @@ -163,4 +194,28 @@ public function testAParentStartsAChildWithItsInputArgumentsOnly(): void self::assertSame('Hello, Ada!', $env->runWorkflowClass(GreetsThroughAChildWorkflow::class, ['name' => 'Ada'])); } + + public function testAContractThatDoesNotExistFailsAtRegistration(): void + { + $this->expectExceptionMessage('#[Activities(unit\\Gplanchat\\Durable\\Workflow\\NoSuchContract)] names no class or interface'); + + (new WorkflowDefinitionLoader())->load(NamesAMissingContractWorkflow::class); + } + + public function testANexusFulfilmentMatchesOnTheInputParametersOnly(): void + { + $this->expectNotToPerformAssertions(); + + // `$greeting` and `$env` are not in the operation's payload, and must not be reported as orphans. + NexusFulfilmentParameterNames::assertMatch('test', GreetingOperation::class, 'greet', 'greet', GreetByArgumentWorkflow::class); + } + + public function testADefaultIsBuiltForEachExecution(): void + { + $env = WorkflowTestEnvironment::inMemory(); + + // A `new` default shared across executions would leak state from one run into the next. + self::assertSame(1, $env->runWorkflowClass(CountsIntoADefaultWorkflow::class)); + self::assertSame(1, $env->runWorkflowClass(CountsIntoADefaultWorkflow::class)); + } } From 2bdc291a043eb7a73cfcb0eb2543a49f55ae7f70 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9gory=20Planchat?= Date: Wed, 23 Sep 2026 19:41:40 +0200 Subject: [PATCH 09/11] fix(phpstan): a nullable stub and a contract given as a string are read as the loader reads them (#419) - @param ActivityStub|null is the natural docblock of ?ActivityStub; the rule strips null before reading the generic, instead of reporting a missing one. - #[Activities('Fqcn')] is accepted by the loader; the rule now reads the string form too, so a disagreeing docblock no longer goes unreported. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/DurablePhpstan/Rules/ActivitiesParameterRule.php | 7 ++++++- .../DurablePhpstan/ActivitiesParameterRuleTest.php | 10 ++++++++++ .../DurablePhpstan/Fixtures/ActivitiesParameters.php | 6 ++++++ 3 files changed, 22 insertions(+), 1 deletion(-) diff --git a/src/DurablePhpstan/Rules/ActivitiesParameterRule.php b/src/DurablePhpstan/Rules/ActivitiesParameterRule.php index 1f570b686..b710b0fd5 100644 --- a/src/DurablePhpstan/Rules/ActivitiesParameterRule.php +++ b/src/DurablePhpstan/Rules/ActivitiesParameterRule.php @@ -9,11 +9,13 @@ use PhpParser\Node; use PhpParser\Node\Expr\ClassConstFetch; use PhpParser\Node\Name; +use PhpParser\Node\Scalar\String_; use PHPStan\Analyser\Scope; use PHPStan\Node\InClassMethodNode; use PHPStan\Rules\IdentifierRuleError; use PHPStan\Rules\Rule; use PHPStan\Rules\RuleErrorBuilder; +use PHPStan\Type\TypeCombinator; /** * Keeps `#[Activities(T::class)]` and `@param ActivityStub` saying the same thing. @@ -55,7 +57,7 @@ public function processNode(Node $node, Scope $scope): array $name = $param->var->name; $phpDoc = $phpDocTypes[$name] ?? null; // A bare `ActivityStub` answers its bound, `object`, which names no class. - $declared = null === $phpDoc ? [] : $phpDoc->getTemplateType(ActivityStub::class, 'TActivity')->getObjectClassNames(); + $declared = null === $phpDoc ? [] : TypeCombinator::removeNull($phpDoc)->getTemplateType(ActivityStub::class, 'TActivity')->getObjectClassNames(); if ([] === $declared) { $short = substr($contract, (int) strrpos($contract, '\\') + 1); @@ -90,6 +92,9 @@ private static function contractOf(Node\Param $param): ?string if ($value instanceof ClassConstFetch && $value->class instanceof Name) { return $value->class->toString(); } + if ($value instanceof String_) { + return ltrim($value->value, '\\'); + } } } diff --git a/tests/unit/DurablePhpstan/ActivitiesParameterRuleTest.php b/tests/unit/DurablePhpstan/ActivitiesParameterRuleTest.php index 2abcdd006..b0e2d836d 100644 --- a/tests/unit/DurablePhpstan/ActivitiesParameterRuleTest.php +++ b/tests/unit/DurablePhpstan/ActivitiesParameterRuleTest.php @@ -26,6 +26,16 @@ public function testADisagreeingDocblockIsReported(): void self::assertNotSame([], $this->matching($this->analyse(), 'Parameter $disagreeing is #[Activities(unit\DurablePhpstan\Fixtures\OrderActivities::class)] but its @param says ActivityStub')); } + public function testANullableStubWithItsGenericIsNotReported(): void + { + self::assertSame([], $this->matching($this->analyse(), '$nullable')); + } + + public function testAContractGivenAsAStringIsCheckedToo(): void + { + self::assertNotSame([], $this->matching($this->analyse(), 'Parameter $namedByString is #[Activities(unit\DurablePhpstan\Fixtures\OrderActivities::class)] but its @param says')); + } + public function testAMissingDocblockIsReportedWithItsFix(): void { // Without the generic, every call on the stub is already "undefined method". The rule diff --git a/tests/unit/DurablePhpstan/Fixtures/ActivitiesParameters.php b/tests/unit/DurablePhpstan/Fixtures/ActivitiesParameters.php index a43f9e36e..09b313026 100644 --- a/tests/unit/DurablePhpstan/Fixtures/ActivitiesParameters.php +++ b/tests/unit/DurablePhpstan/Fixtures/ActivitiesParameters.php @@ -26,6 +26,8 @@ final class ActivitiesParameters /** * @param ActivityStub $agreeing * @param ActivityStub $disagreeing + * @param ActivityStub|null $nullable + * @param ActivityStub $namedByString */ #[AsWorkflowMethod] public function run( @@ -37,6 +39,10 @@ public function run( ActivityStub $disagreeing, #[Activities(OrderActivities::class)] ActivityStub $undocumented, + #[Activities(OrderActivities::class)] + ?ActivityStub $nullable, + #[Activities('unit\\DurablePhpstan\\Fixtures\\OrderActivities')] + ActivityStub $namedByString, ): mixed { return $env->await($agreeing->charge($orderId, 100)); } From e8404ce068783aebbc1a28029f4d6200659e9237 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9gory=20Planchat?= Date: Wed, 23 Sep 2026 19:42:33 +0200 Subject: [PATCH 10/11] fix(phpstan): only a workflow entry method loses its injected parameters (#419) SchedulingMethodReflection serves the activity, Nexus and child stubs alike. An activity method taking a WorkflowEnvironment still needs it from the caller, so the filter now applies to #[AsWorkflowMethod] only. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../Reflection/SchedulingMethodReflection.php | 6 ++++++ tests/unit/DurablePhpstan/Fixtures/StubCallSites.php | 10 ++++++++++ tests/unit/DurablePhpstan/StubMethodsExtensionTest.php | 7 +++++++ 3 files changed, 23 insertions(+) diff --git a/src/DurablePhpstan/Reflection/SchedulingMethodReflection.php b/src/DurablePhpstan/Reflection/SchedulingMethodReflection.php index e8f142ef6..4261df2db 100644 --- a/src/DurablePhpstan/Reflection/SchedulingMethodReflection.php +++ b/src/DurablePhpstan/Reflection/SchedulingMethodReflection.php @@ -4,6 +4,7 @@ namespace Gplanchat\Durable\PHPStan\Reflection; +use Gplanchat\Durable\Attribute\AsWorkflowMethod; use Gplanchat\Durable\Awaitable\Awaitable; use Gplanchat\Durable\Workflow\WorkflowDefinitionLoader; use PHPStan\Reflection\ClassReflection; @@ -73,6 +74,11 @@ private function wrap(ExtendedParametersAcceptor $variant): ExtendedParametersAc private function callerParameters(ExtendedParametersAcceptor $variant): array { $native = $this->contractMethod->getDeclaringClass()->getNativeReflection()->getMethod($this->contractMethod->getName()); + // Only a workflow's entry method has parameters the loader supplies; an activity's or a + // Nexus operation's are all the caller's, whatever their type. + if ([] === $native->getAttributes(AsWorkflowMethod::class)) { + return $variant->getParameters(); + } $injected = []; foreach ($native->getParameters() as $parameter) { if (WorkflowDefinitionLoader::isInjected($parameter)) { diff --git a/tests/unit/DurablePhpstan/Fixtures/StubCallSites.php b/tests/unit/DurablePhpstan/Fixtures/StubCallSites.php index d797f8d5a..ba9b0c33c 100644 --- a/tests/unit/DurablePhpstan/Fixtures/StubCallSites.php +++ b/tests/unit/DurablePhpstan/Fixtures/StubCallSites.php @@ -30,6 +30,13 @@ public function charge(string $orderId, int $amount): string; public function helper(): string; } +/** An activity is not a workflow: none of its parameters is supplied by the loader. */ +interface AuditActivities +{ + #[AsActivityMethod('audit')] + public function audit(WorkflowEnvironment $env): string; +} + #[AsNexusService('billing')] interface BillingServed { @@ -98,6 +105,9 @@ public function run(string $orderId): mixed // Correct: the child's entry method. $this->environment->await($this->child->run('bonjour')); + // WRONG — an activity's parameters are all the caller's, whatever their type. + $this->environment->await($this->environment->activityStub(AuditActivities::class)->audit()); + // Correct: the input argument only; the environment is the loader's to supply. $this->environment->await($this->environment->childWorkflowStub(InjectingChildWorkflow::class)->run('bonjour')); diff --git a/tests/unit/DurablePhpstan/StubMethodsExtensionTest.php b/tests/unit/DurablePhpstan/StubMethodsExtensionTest.php index a1fe24a2c..b40c0bb62 100644 --- a/tests/unit/DurablePhpstan/StubMethodsExtensionTest.php +++ b/tests/unit/DurablePhpstan/StubMethodsExtensionTest.php @@ -115,6 +115,13 @@ public function testAChildIsCalledWithoutTheParametersItsLoaderInjects(): void self::assertSame([], $this->matching($errors, 'InjectingChildWorkflow::run() invoked with')); } + public function testOnlyAWorkflowMethodLosesItsInjectedParameters(): void + { + $errors = $this->analyse(withExtension: true); + + self::assertNotSame([], $this->matching($errors, 'AuditActivities::audit() invoked with 0 parameters, 1 required')); + } + public function testAReadonlyPropertyIsEnoughToCarryTheContract(): void { // The fixture declares its stubs `readonly` **without** a `@var` annotation. If this test From 9de34c8d34eac4f381be73b27e152617693604f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9gory=20Planchat?= Date: Wed, 23 Sep 2026 19:42:48 +0200 Subject: [PATCH 11/11] docs: the French anchor is pinned, and the error list and PHPStan identifier are complete (#419) Co-Authored-By: Claude Opus 5.5 (1M context) --- .../user/getting-started/_index.fr.md | 2 +- documentation/user/workflows/_index.fr.md | 20 +++++++++---------- documentation/user/workflows/_index.md | 7 ++++--- 3 files changed, 15 insertions(+), 14 deletions(-) diff --git a/documentation/user/getting-started/_index.fr.md b/documentation/user/getting-started/_index.fr.md index 6bf9690ce..56a3b0bf9 100644 --- a/documentation/user/getting-started/_index.fr.md +++ b/documentation/user/getting-started/_index.fr.md @@ -256,7 +256,7 @@ final class GreetWorkflow `$name` vient de l'entrée avec laquelle le workflow démarre. `$greeting` et `$env`, non : Durable les fournit, comme Symfony fournit ses services à un contrôleur. Voir -[Les arguments que fournit Durable](../workflows/#les-arguments-que-fournit-durable). +[Les arguments que fournit Durable](../workflows/#arguments-durable-supplies). ### 4. Le déclencher depuis un contrôleur ou un service {#4--le-déclencher-depuis-un-contrôleur-ou-un-service} diff --git a/documentation/user/workflows/_index.fr.md b/documentation/user/workflows/_index.fr.md index 62cf053b8..6f33a59f6 100644 --- a/documentation/user/workflows/_index.fr.md +++ b/documentation/user/workflows/_index.fr.md @@ -224,7 +224,7 @@ Un message enregistré après le déclenchement de l'échéance n'est jamais app cette échéance a tranchée ; il reste disponible pour l'attente suivante, et son gestionnaire s'exécute à ce moment-là. Voir **DUR032** et **DUR035**. -### Les arguments que fournit Durable +### Les arguments que fournit Durable {#arguments-durable-supplies} La méthode du workflow peut recevoir ses stubs d'activités et son environnement en arguments, au lieu de les construire dans un constructeur : @@ -246,18 +246,18 @@ public function run( - Un paramètre typé **`ActivityStub`** et marqué **`#[Activities(Contrat::class)]`** reçoit `$env->activityStub(Contrat::class)`. PHP n'a pas de génériques à l'exécution : c'est l'attribut qui nomme le contrat. -- Tout autre paramètre est une **entrée**, lue par son nom, comme avant. Personne ne passe les - paramètres fournis : ni le code qui démarre le workflow, ni un parent qui l'appelle comme enfant, - ni une opération Nexus. +- Tout autre paramètre est une **entrée**, lue par son nom, comme avant. Les paramètres fournis ne + sont jamais passés par l'appelant : ni par le code qui démarre le workflow, ni par un parent qui + l'appelle comme enfant, ni par une opération Nexus. - Le docblock **`@param ActivityStub`** sert à PHPStan. Avec [`gplanchat/durable-phpstan`](../packages/), un docblock qui nomme un autre contrat que - l'attribut est une erreur, et son absence aussi, puisque PHPStan ne peut pas vérifier les appels - sans lui. + l'attribut est une erreur, et son absence aussi (`durable.activities.missingGeneric`, que l'on + peut ignorer), puisque PHPStan ne peut pas vérifier les appels sans lui. - Un stub qui a besoin d'**`ActivityOptions`** garde `$env->activityStub($contrat, $options)` : les arguments d'un attribut ne savent pas construire une `Duration`. -- Les erreurs tombent à l'**enregistrement** du workflow (compilation du conteneur, avec le - bundle) : un `ActivityStub` sans `#[Activities]`, un `#[Activities]` sur un autre type, ou un - contrat qui ne déclare aucun `#[AsActivityMethod]`. +- Les erreurs surviennent dès l'**enregistrement** du workflow (compilation du conteneur, avec le + bundle) : un `ActivityStub` sans `#[Activities]`, un `#[Activities]` sur un autre type, un + contrat introuvable, ou un contrat qui ne déclare aucun `#[AsActivityMethod]`. La forme par constructeur reste valable. C'est celle qu'il faut quand la classe implémente une interface de contrat comme `OrderWorkflowContract` plus haut : PHP n'autorise pas l'implémentation à @@ -331,7 +331,7 @@ ce qu'un workflow peut faire, et rien de ce que le moteur garde pour lui. | `some($count, ...$awaitables)` | Se résout quand `$count` membres ont **réussi**, indexés par position de déclaration. Les autres sont annulés. | | `timer($duration, $summary = '')` | Un awaitable qui se résout à l'échéance de la durée. Se compose comme n'importe quel autre. | | `sleep($duration, $summary = '')` | Attend, et fait l'attente pour vous. Dit ce qu'il fait. | -| `activityStub($contract, $options = null)` | Un proxy typé sur un contrat d'activité. Construisez-le dans le constructeur, ou déclarez-le en [argument `#[Activities]`](#les-arguments-que-fournit-durable) s'il n'a pas besoin d'options ; tous ses appels portent `$options`. | +| `activityStub($contract, $options = null)` | Un proxy typé sur un contrat d'activité. Construisez-le dans le constructeur, ou déclarez-le en [argument `#[Activities]`](#arguments-durable-supplies) s'il n'a pas besoin d'options ; tous ses appels portent `$options`. | | `childWorkflowStub($class, $options = null)` | Le même, pour un workflow enfant : résolu depuis la classe de l'enfant, et ses appels se composent comme les autres. | | `onSignal($name, $handler)` | Enregistre un gestionnaire de signal. Le gestionnaire mute l'état du workflow et `await()` l'observe ; il n'y a pas d'attente séparée. Le nom prend une énumération adossée, donc une faute de frappe est une erreur de type et non une attente qui ne se résout jamais. | | `onUpdate($name, $handler)` | Le même pour une mise à jour, dont la valeur de retour du gestionnaire est la réponse rendue à l'appelant. | diff --git a/documentation/user/workflows/_index.md b/documentation/user/workflows/_index.md index ca367abcf..862f7fa08 100644 --- a/documentation/user/workflows/_index.md +++ b/documentation/user/workflows/_index.md @@ -245,12 +245,13 @@ public function run( Nexus operation. - The **`@param ActivityStub`** docblock is for PHPStan. With [`gplanchat/durable-phpstan`](../packages/), a docblock that names another contract than the - attribute is an error, and so is a missing one, since PHPStan cannot check the calls without it. + attribute is an error, and so is a missing one (`durable.activities.missingGeneric`, which a + project can ignore), since PHPStan cannot check the calls without it. - A stub that needs **`ActivityOptions`** keeps `$env->activityStub($contract, $options)`: attribute arguments cannot build a `Duration`. - Mistakes fail when the workflow is **registered** (container compilation, with the bundle): an - `ActivityStub` without `#[Activities]`, `#[Activities]` on another type, or a contract that - declares no `#[AsActivityMethod]`. + `ActivityStub` without `#[Activities]`, `#[Activities]` on another type, a contract that does not + exist, or one that declares no `#[AsActivityMethod]`. The constructor form keeps working. It is the one to use when the class implements a contract interface such as `OrderWorkflowContract` above: PHP does not let the implementation add required