PHPStan extension for gplanchat/durable. It resolves the
calls of ActivityStub, ChildWorkflowStub and NexusStub from their typed contract.
Read-only mirror. This repository is a subtree-split of gplanchat/durable-dev, published so Composer can require this package on its own. Issues and pull requests are disabled here — open them on the monorepo.
The tests are in the monorepo, not here. This split carries source only. What covers it is
tests/unit/DurablePhpstan/in the monorepo, run by itsunitsuite.Documentation: durable.rocks.
For NexusStub, it also follows inheritance: a Nexus contract splits into two interfaces — the
one the handler implements and the one that extends it for the caller —, and the stub calls both of
them.
composer require --dev gplanchat/durable-phpstanWith phpstan/extension-installer, nothing more.
Otherwise, in your phpstan.neon:
includes:
- vendor/gplanchat/durable-phpstan/extension.neonThe stubs resolve their calls through __call(). Without an extension, PHPStan sees nothing but
objects with no methods and reports every stub call — the correct ones as well as the faulty:
$this->orders->charge($orderId, 100); // without the extension: "undefined method" — false
$this->orders->chrage($orderId, 100); // without the extension: "undefined method" — trueThe flaw, then, is not the silence, it is the noise. Four errors of which two are false go into a baseline or get ignored in one block, and the two true ones leave along with them — which comes to the same thing as checking nothing at all, only more expensive.
Since decisions DUR038 and DUR039, the typed stub is the only way to schedule an activity or a child workflow. That check is therefore the only one left.
On the same fixture, measured:
| without the extension | with | |
|---|---|---|
charge() — correct call |
✗ wrongly reported | ✓ |
run() — child, correct call |
✗ wrongly reported | ✓ |
chrage() — typo |
✗ | ✗ |
helper() — without #[AsActivityMethod] |
✗ | ✗ |
charge($id) — one argument out of two |
invisible | ✗ arity checked |
The last row is the gain the noise was masking: once the method is known, PHPStan compares the arguments against what the contract declares.
The stub carries its contract as a generic parameter, and PHPStan infers it from
WorkflowEnvironment::activityStub(). It still has to be able to follow it all the way to the call
site — which is the case as soon as the property is readonly and assigned once, in the
constructor:
private readonly ActivityStub $orders; // enough
public function __construct(WorkflowEnvironment $environment)
{
$this->orders = $environment->activityStub(OrderActivities::class);
}A mutable property loses the parameter between the constructor and the method. Annotate it explicitly in that case:
/** @var ActivityStub<OrderActivities> */
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.
#[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:
/** @param ActivityStub<OrderActivities> $orders */
#[AsWorkflowMethod]
public function run(
string $orderId,
#[Activities(OrderActivities::class)]
ActivityStub $orders,
WorkflowEnvironment $env,
): mixedThe 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.
durable.activityStubCouldBeParameter reports a stub built with $env->activityStub() that the
workflow method could receive as an #[Activities] parameter instead. It reports, it does not
rewrite: the message gives the attribute and the docblock to write, and you move the stub by hand.
Activity stub $orders could be a parameter of run(): #[Activities(OrderActivities::class,
attempts: 3)] ActivityStub $orders, documented with @param ActivityStub<OrderActivities> $orders.
It reports a stub built in the workflow method, or built in the constructor and read by that
method only, with no options or with ActivityOptions::of() and literal values. It stays silent
when it can see that the move would change what runs:
- options computed at run time,
ActivityOptions::default(), anof()that sets nothing or only an emptynonRetryableExceptionslist (the attribute would build the stub with no options, and the Durable worker would retry without backoff), an emptytaskQueue, anactivityId, or abackoffCoefficientormaximumIntervalthe attribute refuses at registration; - a stub that a signal, update or helper method reads, that the constructor reads after building it, or that a closure, an arrow function or an anonymous class reads;
- a local name assigned twice, or one that is already a parameter of the method;
- a class that extends another, implements an interface or uses a trait, any of which may declare or call the workflow method.
Code that already fails, or a nonRetryableExceptions entry that never matches, is still
reported, with a warning: after the move, the worker refuses to register the workflow. That is the
case for:
of(0), which fails on every run;- a
nonRetryableExceptionsentry that is not a\Throwableclass (an unknown class, orself::classin a workflow):of()accepts it, and it never matches an exception; - a contract with no
#[AsActivityMethod]method, which fails on the first call; - a contract that names no class or interface.
The rule does not see every way code reaches the stub. In these cases a report can be a false positive, and the stub has to stay:
- another method of the class calls the workflow method, which would then lack the stub;
__callor a variable method name reaches the workflow method;- reflection,
get_object_vars()or an(array)cast reads the property; - the local name is used before the stub is built, for example in an
isset()or a by-reference argument.
When the stub must stay where it is, ignore the report in phpstan.neon:
parameters:
ignoreErrors:
- identifier: durable.activityStubCouldBeParameterAdd a path: to scope it, or put // @phpstan-ignore durable.activityStubCouldBeParameter on the
line of the call.
A workflow method is replayed from its journal, so a value it reads from the clock or from a random
source differs on every replay. In a class carrying #[AsWorkflow], the extension reports these
reads with the identifier durable.nondeterministic:
- the functions
time(),microtime(),hrtime(),rand(),mt_rand(),random_int(),uniqid(),sleep(),usleep(),now(),today(), anddate()without a timestamp; newon a class implementingDateTimeInterface(DateTime,DateTimeImmutable, Symfony'sDatePoint, Carbon, your own subclasses) without an argument or with a relative string such as'now'or'tomorrow', and::now(),::today(),::tomorrow(),::yesterday()or::parse()without an absolute date on those classes;now(),sleep()andusleep()on aPsr\Clock\ClockInterface.
An absolute date ('2026-01-01'), a timestamp ('@1700000000'), an argument whose value is not a
constant, and anything inside a closure passed to $env->sideEffect() are not reported.
$deadline = new \DateTimeImmutable('+3 days'); // reported
$deadline = $env->sideEffect(static fn () => new \DateTimeImmutable('+3 days')); // journalledThe whole class is checked, signal and query handlers and private helpers included. A read in a helper that the workflow calls is reported too, at the workflow's call, with the calls in between:
Via App\Billing\Stamp::today → App\Billing\Stamp::now: ->now() on a clock reads the current time …
The walk follows method and constructor calls. It stops at an interface (the implementations are not
followed), at plain functions, at callbacks and at classes under vendor/. Calls to Durable itself
($env->await(), $env->sleep()) are not followed: they are the API a workflow is meant to call.
Calls made through an activity stub run in an activity, where the clock may be read.
A method absent from the contract, or present but without #[AsActivityMethod] — respectively
#[AsWorkflowMethod] for a child — stays unknown. That is deliberate: the stub already refuses it at
execution time with a BadMethodCallException, and the analysis now says so beforehand.
MIT.