Skip to content

feat(workflow): the workflow method receives its stubs and its environment as arguments (#419) - #423

Merged
gplanchat merged 12 commits into
mainfrom
feat/workflow-method-arguments
Sep 23, 2026
Merged

gplanchat merged 12 commits into
mainfrom
feat/workflow-method-arguments

Conversation

@gplanchat

Copy link
Copy Markdown
Owner

Closes #419.

A workflow method receives its activity stubs and its environment as arguments, the way a controller receives its services:

/** @param ActivityStub<GreetingActivities> $greeting */
#[AsWorkflowMethod]
public function run(
    string $name,
    #[Activities(GreetingActivities::class)]
    ActivityStub $greeting,
    WorkflowEnvironment $env,
): string {
    return $env->await($greeting->greet($name));
}

What changes

Where Change
WorkflowDefinitionLoader Plans the arguments once in load(): WorkflowEnvironment by type, #[Activities] stubs through $env->activityStub(), everything else from the input by name. A replay only runs the plan. run(array $input) counts input parameters only.
WorkflowDefinitionLoader Refuses at load(): an ActivityStub without #[Activities], #[Activities] on another type, a contract that does not exist or declares no #[AsActivityMethod].
inputParameters() The one filter for injected parameters, used by workflowMethodParameters() (Nexus payload names), ChildWorkflowStub (through a new optional parameter list on StubArguments::toPayload()) and the PHPStan extension.
WorkflowPass (bundle) Loads each tagged workflow at compile time, so a refused one fails the container compilation instead of the first worker.
durable-phpstan ActivitiesParameterRule: durable.activities.contractMismatch when the @param ActivityStub<T> names another contract than the attribute, durable.activities.missingGeneric when it is missing. SchedulingMethodReflection drops injected parameters from a child workflow's entry method, and only there.
Docs Getting started shows the argument form; the workflows page explains what Durable supplies (EN + FR); the durable-phpstan README documents both identifiers.

Constructor injection keeps working. It stays the form for a class implementing a contract interface: PHP does not let the implementation add required parameters to run().

Deviations from the first version of #419

Recorded in the issue:

  • #[AsActivity] is optional on a contract, so validation checks for at least one #[AsActivityMethod] instead.
  • No options: on the attribute: Duration and RetryLimit have private constructors, which attribute arguments cannot reach. A stub with options keeps $env->activityStub($contract, $options).
  • Errors surface at container compilation, not cache:warmup.
  • PHPStan has no non-failing level, so a missing docblock is an error with its own ignorable identifier.

Review

A fresh-context review found no blocker. Its findings are fixed in the last four commits:

  • an input default built per execution rather than shared by a worker;
  • the PHPStan rule reading ActivityStub<T>|null and #[Activities('Fqcn')];
  • the stub-parameter filter limited to workflow methods;
  • two missing tests (Nexus fulfilment, a contract that does not exist);
  • documentation details.

Checks

  • tests/unit/Durable, tests/unit/DurablePhpstan, tests/unit/DurableBundle/DependencyInjection: 880 tests, green. Every behaviour change arrived with its failing test first.
  • Every commit is under 200 changed lines.
  • loop/guardrails/verify.sh is red locally for a reason outside this diff. The local vendor/ lacks doctrine/orm and illuminate/cache, which breaks the Laravel, Illuminate and SchemaListener tests and five PHPStan findings in files this PR does not touch. The same failures occur on main. CI installs from the lock and is the reference.

gplanchat and others added 11 commits September 23, 2026 19:50
…nment 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) <noreply@anthropic.com>
…istration (#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) <noreply@anthropic.com>
…f 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) <noreply@anthropic.com>
…lation (#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) <noreply@anthropic.com>
…me 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) <noreply@anthropic.com>
…rted once, where it is fixed (#419)

Without @PARAM ActivityStub<T>, 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) <noreply@anthropic.com>
…rguments (#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) <noreply@anthropic.com>
…als 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) <noreply@anthropic.com>
…ad as the loader reads them (#419)

- @PARAM ActivityStub<T>|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) <noreply@anthropic.com>
…ers (#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) <noreply@anthropic.com>
…ntifier are complete (#419)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@gplanchat gplanchat added enhancement New feature or request core src/Durable bundle src/DurableBundle labels Sep 23, 2026
gplanchat added a commit that referenced this pull request Sep 23, 2026
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@gplanchat
gplanchat enabled auto-merge September 23, 2026 18:34
@gplanchat
gplanchat merged commit 00e1c69 into main Sep 23, 2026
37 checks passed
@gplanchat
gplanchat deleted the feat/workflow-method-arguments branch September 23, 2026 19:22
gplanchat added a commit that referenced this pull request Sep 23, 2026
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bundle src/DurableBundle core src/Durable enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Task] Workflow method arguments: inject #[Activities] stubs and WorkflowEnvironment

1 participant