gplanchat / durable-rector
Rector rules that migrate a project off the official Temporal PHP SDK onto gplanchat/durable — attribute rewrites that keep the workflow and activity type names a running server already knows
Requires
- php: >=8.2
- gplanchat/durable: v0.1.0-alpha10
- rector/rector: ^2.0
Requires (Dev)
- phpunit/phpunit: ^11.0
This package is auto-updated.
Last update: 2026-09-01 10:00:48 UTC
README
Rector rules for projects that consume gplanchat/durable
and have code to migrate. Two sets, for two different migrations.
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/DurableRector/in the monorepo, run by itsrectorsuite.Documentation: durable.rocks.
composer require --dev gplanchat/durable-rector
| Set | For |
|---|---|
temporal-sdk.php |
Coming from the official Temporal PHP SDK, keeping the workflow and activity type names a running server already knows |
durable-upgrade.php |
Already on Durable, moving from one version to the next — cumulative, and detailed version by version in UPGRADE.md |
// rector.php return Rector\Config\RectorConfig::configure() ->withImportNames() // or the rewritten names land fully qualified, next to a stale `use` ->withSets([__DIR__ . '/vendor/gplanchat/durable-rector/config/sets/temporal-sdk.php']);
Monter de version à l'intérieur de Durable
Le tableau ci-dessus fait entrer un projet dans Durable, une fois. durable-upgrade.php l'y
fait avancer, à chaque montée de version :
// rector.php return Rector\Config\RectorConfig::configure() ->withImportNames() ->withSets([__DIR__ . '/vendor/gplanchat/durable-rector/config/sets/durable-upgrade.php']);
Il est cumulatif — le passer une fois rattrape toutes les versions franchies. Ce qu'il contient, et
surtout ce qu'il ne peut pas faire tout seul (un conteneur Symfony compilé garde les noms
pleinement qualifiés, et veut son cache:clear), est écrit version par version dans
UPGRADE.md à la racine du dépôt.
temporal-sdk.php — coming off the SDK
What it does
| Rule | What moves |
|---|---|
ActivityContractAttributesRector |
#[ActivityInterface(prefix:)] → #[Activity(name:)], and every public method gets an explicit #[ActivityMethod(name:)] |
WorkflowClassAttributesRector |
#[WorkflowInterface] and the four method attributes are copied from the interface onto the implementing class, where Durable reads them |
RenameClassRector (configured) |
The three SDK failures with a Durable counterpart |
TemporalFacadeToEnvironmentRector |
The static facade becomes an injected WorkflowEnvironment, yield goes, and the \Generator return type with it |
UnmigratableTemporalCallRector |
Comments every call the migration cannot make, and changes nothing else |
Why the names are the whole point
Both engines derive a type name, and they derive it differently:
- The SDK's activity type is
prefix . (name ?? methodName)— one concatenation, no separator inserted. Durable's isActivity::$name . '.' . ActivityMethod::$name, and the dot is not optional. The two agree on exactly two prefixes: the empty one, and one ending in a dot. On any other prefix this rule changes nothing and leaves the SDK attribute in place, rather than rename an activity that has runs in flight. - The SDK's workflow type is
#[WorkflowMethod(name:)]if given, else the interface's short name. Durable's#[Workflow]is optional and falls back to the class's short name. A class migrated without an explicit name therefore compiles, passes its tests, and stops resolving every run already started. The rule always writes the name out — and overtemporalio/samples-php, 24 of the 27 names it writes are ones the fallback would have got wrong. - The SDK treats every public method of an
#[ActivityInterface]as an activity; Durable only an annotated one. Methods that carried no#[ActivityMethod]get one, named after themselves.
It adds, it never removes
The SDK attributes stay on the interface. A rule cannot read an attribute another rule has just
deleted in the same pass, and leaving them costs nothing — Durable ignores them, and
composer remove temporal/sdk is the honest forcing function for the cleanup.
The execution model
Workflow:: is static and $this->environment is not, so the rule adds a promoted
WorkflowEnvironment constructor parameter — prepended, because Durable resolves the constructor by
type, and prepending never puts a required parameter after an optional one.
yield is what says whether a call waits, and it is the only thing that says it. yield Workflow::timer($d) waits, so it becomes sleep($d); a bare Workflow::timer($d) handed to a race
assembles, so it becomes timer($d). yield $stub->charge() becomes await($stub->charge()),
because a stub assembles and await() is the only wait. Promise::all($p) becomes all(...$p) —
one iterable on that side, variadic on this one — and Promise::some($p, 2) becomes some(2, ...$p).
Two arities it refuses. The SDK's Workflow::await(...$conditions) is variadic and settles on
the first condition; Durable's second parameter is a deadline. One condition maps —
awaitWithTimeout($t, $c) becomes await($c, $t) — and more than one does not, because rewriting
it would quietly turn a second condition into a timeout. Those call sites are left exactly as they
are and reported instead.
A callable that is not a \Closure. The SDK takes callable where Durable takes \Closure,
so Workflow::sideEffect([$this, 'compute']) rewrites to a TypeError on first run. It fails
loudly rather than silently, so the rule does not refuse it — but an array or string callable is
worth grepping for before you run the workflow.
Return types are removed, never written. A de-yielded method may not keep \Generator; what it
actually returns, the SDK could not declare and this rule will not guess. An interface that declared
\Generator loses it too — otherwise the class would widen its own contract, which is fatal.
Two things it refuses to touch. A static method has no $this: it gets a marker, not a
rewrite. And a class that is not workflow code is left alone entirely — yield is ordinary PHP, and
an interceptor in the official samples yields reflection attributes out of a plain iterator. A class
qualifies by implementing an #[WorkflowInterface] contract or by calling the facade. Inside one
that does, every non-static method is rewritten, helpers included: an SDK workflow is
generator-coloured throughout, which is the problem being removed. The one shape to check by hand
afterwards is a plain iterator generator living inside a workflow class.
The report: what cannot be migrated at all
UnmigratableTemporalCallRector writes a durable-rector: comment above any statement calling a
Workflow:: method Durable has no answer for, and leaves the code untouched. It answers the
question that comes before the migration — a workflow built on Workflow::async() and
Workflow::runLocked() is a redesign, not a long rewrite — and git checkout undoes it.
It works from an allow-list: seven facade methods are recognised as ones the execution-model
half will rewrite (newActivityStub, newChildWorkflowStub, await, awaitWithTimeout, timer,
sideEffect, continueAsNew), and everything else is reported. Workflow:: carries some forty
static methods and WorkflowEnvironment answers eight; a deny-list would pass in silence every one
nobody enumerated, the next SDK release included.
It also reports the options objects — ActivityOptions, RetryOptions,
ChildWorkflowOptions, ContinueAsNewOptions, LocalActivityOptions — in the same pass that
rewrites the call around them. ActivityOptions::new()->withStartToCloseTimeout(…) has no
counterpart in ActivityOptions::of() over ActivityTimeouts and RetryLimit; rewritten silently,
the result would read as migrated and could not run.
Run against temporalio/samples-php, the whole set
changes 58 files — and it reports
coroutines (async, asyncDetached), the mutex (runLocked, Mutex), run introspection
(getInfo, getCurrentContext, isReplaying), the saga helper, activity-by-name, in-run search
attributes, and the options objects.
What it does not do
Write a return type. The \Generator goes; nothing replaces it. Declaring what a migrated
method returns is yours, and the contract's docblock is usually where it is written down.
Migrate the options objects, interceptors, or a Saga. It reports them. See OST004 §6.
Anything with no counterpart — it reports those rather than pretending. Workflow::getVersion()
has no target at all until workflow versioning lands; Workflow::newUntypedActivityStub() and
activity-by-name calls were removed on purpose
(DUR039).
Development
temporal/sdk is not a dependency, of this package or of the monorepo — see
DUR006. Rector
matches attributes by fully-qualified name and never loads them, so the tests declare the shape they
read in tests/unit/DurableRector/Source/temporal-sdk-stubs.php, under the SDK's own namespace.
vendor/bin/phpunit --testsuite unit --filter DurableRector