Creating a workflow#
This page describes how you write a workflow in Durable, and the API a workflow uses. A workflow is the PHP class that describes the steps of an execution (one durable run, from its start to its end); its side effects run in activities, and each step is recorded in the journal (the append-only history of an execution). The glossary defines each term.
The normative rules are in the contributor ADRs DUR022 and related decisions (DUR003, DUR013). This page covers their practical use.
Example: minimal workflow#
A workflow is a class annotated with #[AsWorkflow] and registered with the runtime. Its
workflow method takes the input, and Durable supplies the rest as arguments: the activity stubs and
the environment (see Arguments Durable supplies). With the current
loader, the #[AsWorkflow] attribute goes on the class (see DUR022 for the long-term interface-first model).
<?php
declare(strict_types=1);
use Gplanchat\Durable\Activity\ActivityStub;
use Gplanchat\Durable\Attribute\Activities;
use Gplanchat\Durable\Attribute\AsWorkflow;
use Gplanchat\Durable\Attribute\AsWorkflowMethod;
use Gplanchat\Durable\WorkflowEnvironment;
#[AsWorkflow(name: 'order')]
final class OrderWorkflow
{
/** @param ActivityStub<OrderActivities> $activities */
#[AsWorkflowMethod]
public function run(
string $orderId,
#[Activities(OrderActivities::class)]
ActivityStub $activities,
WorkflowEnvironment $env,
): mixed {
// Activity contract: see Creating activities. The stub schedules work; await runs it in the replay model.
return $env->await($activities->charge($orderId));
}
}WorkflowEnvironment provides await, the assemblers all / any / some, timers, child workflows, signals, and more. The class in the repository has the full API.
Waiting versus assembling#
await() is the only method that waits. Every other call assembles. A stub call, timer() and
the assemblers below all return an Awaitable and return immediately.
$env->sleep(Duration::minutes(5)); // wait, and nothing else; awaits for you
$winner = $env->await($env->any( // assemble, then wait
$activities->callProvider($orderId),
$activities->callFallbackProvider($orderId),
));The three assemblers differ by how many members have to finish:
$env->all($a, $b, $c) // Awaitable of [$a, $b, $c]: every member, in declaration order
$env->any($a, $b, $c) // Awaitable of the first member to settle, whatever its fate
$env->some(2, $a, $b, $c) // Awaitable of the first 2 members to succeed, keyed by position
Because they return an Awaitable instead of a value, they compose. An assembly nests in
another, and an assembly can be bounded by a deadline.
$quotes = $env->await($env->some(3, ...$providers), deadline: Duration::seconds(2));some() counts only members that succeed. A provider that fails does not bring the quorum
closer, and once too few members remain to reach it, the wait fails. all() is the full quorum, so one failed member fails the whole assembly. any() is a
race, so the first member to settle wins, even when it settles by failing.
Losing branches are cancelled. Their activities are removed from the queue and their timers no longer wake the execution, branches nested inside an assembly included.
timer() returns an Awaitable exactly like a stub call, so both compose the same way. Both
accept a Duration, a DateInterval (so a CarbonInterval), a DateTimeInterface deadline, or a
plain number of seconds.
Bounding a wait in time#
To give up on a wait after a while, pass a deadline to await(). Do not race a timer by hand:
any() resolves to the winning value and nothing else, so a provider that legitimately answers
null is indistinguishable from an elapsed deadline, and a saga that compensates on timeout would
compensate on an empty answer too.
use Gplanchat\Durable\Exception\DeadlineExceededException;
try {
$quote = $env->await($activities->callProvider($orderId), deadline: Duration::seconds(30));
} catch (DeadlineExceededException $e) {
// The provider did not answer in time. Compensation path.
// $e->deadline() is the deadline that elapsed, $e->awaited() what it was bounding.
}The deadline defaults to Duration::infinity(). An unbounded wait is therefore expressed by a
value instead of a missing argument, and a caller that computes its own deadline has no “no bound”
case to handle separately.
An elapsed deadline raises an exception and never returns a sentinel value. When the work settles
in time, null, false and [] come back untouched.
Waiting on a condition#
await() also takes a condition, a predicate over the workflow’s own state, wherever it
takes an awaitable, with the same optional deadline. A signal handler (a method that receives a
message sent to the execution from outside; see the glossary) changes that state and
wakes the wait:
$env->onSignal(OrderSignal::Approve, fn(array $p) => $this->approvals[] = $p);
try {
$env->await(fn(): bool => [] !== $this->approvals, deadline: Duration::hours(1));
} catch (DeadlineExceededException) {
return $this->expire($orderId);
}This example waits for an approval and gives up after an hour.
To describe what a condition waits for in words, pass a label. On the backends that record
the wait (in-memory, DBAL, Illuminate, Temporal), the run list then shows the label instead of the
place where the closure is written, waiting on signal approve instead of
waiting on condition at src/…/OrderWorkflow.php:42. The Magento grid shows it on Temporal (see the
dashboard page).
$env->await(fn(): bool => [] !== $this->approvals, deadline: Duration::hours(1), label: 'signal approve');The label is display text. Nothing records it while the workflow waits. If the deadline expires and
nothing catches the exception, the label appears in the failure message the journal keeps, written
once. Replay (running the workflow code again from its first line, with each recorded step returning
its result) never compares it, so you can add, change or remove a label safely. A timer or an
activity already names itself, so passing a label to await() with one throws an InvalidArgumentException.
Declaring the handler and the wait as methods#
In a workflow written as a class, declare the handler with #[AsSignalMethod], which the engine
wires, and pair it with a small private method that waits and consumes. The body then reads as one
line, and the buffer is a property instead of a captured reference:
#[AsWorkflow('Order')]
final class OrderWorkflow
{
/** @var list<array<string, mixed>> */
private array $approvals = [];
public function __construct(private readonly WorkflowEnvironment $env) {}
#[AsSignalMethod(OrderSignal::Approve)]
public function approve(array $payload): void
{
$this->approvals[] = $payload;
}
#[AsWorkflowMethod]
public function run(string $orderId): string
{
$approval = $this->waitApproval(Duration::hours(48));
return $this->ship($orderId, $approval['by']);
}
/** @return array<string, mixed> */
private function waitApproval(Duration $deadline): array
{
$this->env->await(fn(): bool => [] !== $this->approvals, deadline: $deadline);
return array_shift($this->approvals);
}
}The deliveries are workflow state. A workflow that waits for the same signal three times keeps
three entries and consumes them at its own pace, and a signal that arrived while nothing was
waiting is still there at the next wait. The removed waitSignal() needed an engine-side counter
to approximate this; here array_shift() does it.
Two mistakes produce no error when you make them.
The attribute is read from the workflow class, with
ReflectionClass::getMethods(). PHP does not surface an attribute declared on an interface method through the class implementing it, so#[AsSignalMethod]on a contract interface registers nothing. The signal arrives, no handler runs, and the condition never holds. Put the attribute on the class.The handler is called with one argument, the payload array. A signature like
approve(string $by)fails when the signal is delivered. Starting the worker raises no error for it.
A condition must be a function of workflow state and nothing else. It is re-evaluated on every
replay, so record anything a replay cannot reproduce (a clock, a random draw, an environment
variable) once with sideEffect(), and read it back:
$threshold = $env->sideEffect(fn(): int => random_int(1, 10)); // recorded once
$env->await(fn(): bool => $this->received >= $threshold); // replays identically
Durable does not detect a condition that breaks this rule, nor any other non-determinism.
sideEffect() is the mechanism to use instead.
fn()captures by value. A condition over a local variable must use the long form,function () use (&$approvals): bool { … }. Over$this->propertythe short form works, because$thisis captured and the property is read through it.
A condition that can never hold, because nothing pending can change the state it reads, is reported as an execution that cannot advance, with the condition named by its file and line.
Whichever branch loses is cancelled. A deadline that elapses cancels the work it bounded, and work that settles cancels the deadline, so no dead timer wakes the execution later. Cancelling an in-flight activity is best effort. Temporal receives a cancellation request, and an attempt that does not honour it may keep running on its worker. Once the deadline has elapsed, that attempt’s completion no longer resumes your workflow.
The verdict is read from recorded history, so a replay reaches the verdict the original execution reached, including when the awaited signal is delivered after the deadline elapsed. A message 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:
/** @param ActivityStub<OrderActivities> $orders */
#[AsWorkflowMethod]
public function run(
string $orderId,
#[Activities(OrderActivities::class)]
ActivityStub $orders,
WorkflowEnvironment $env,
): mixed {
return $env->await($orders->charge($orderId));
}-
A parameter typed
WorkflowEnvironmentreceives the environment. -
A parameter typed
ActivityStuband 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. 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<Contract>docblock is for PHPStan. Withgplanchat/durable-phpstan, a docblock that names another contract than the attribute is an error (durable.activities.contractMismatch), and so is a missing one (durable.activities.missingGeneric, which a project can ignore), since PHPStan cannot check the calls without it. -
The attribute also takes the stub’s options, as scalars. An attribute argument cannot call
Duration::seconds(), so durations are given in seconds:#[Activities(OrderActivities::class, attempts: 3, startToClose: 120.0, heartbeat: 10.0, nonRetryable: [CardDeclined::class], taskQueue: 'payments')] ActivityStub $activities,Available:
attempts,startToClose,scheduleToClose,scheduleToStart,heartbeat,initialInterval,backoffCoefficient,maximumInterval,nonRetryable,taskQueue,cancellationType,summary. Each one left out keeps itsActivityOptionsdefault; with none, the stub is the oneactivityStub()builds without options. On Temporal, a stub with nostartToCloseorscheduleToClosegets a 30-second bound per attempt.heartbeatneeds Temporal: a journal backend throwsUnsupportedByBackendExceptionwhen the stub schedules an activity with it (see Options and value objects). -
Mistakes fail when the workflow is registered (container compilation, with the bundle): an
ActivityStubwithout#[Activities],#[Activities]on another type, a contract that does not exist, one that declares no#[AsActivityMethod], or an impossible option (zero attempts, a negative duration, a heartbeat longer thanstartToClose, abackoffCoefficientbelow 1, amaximumIntervalshorter than the first retry delay, a non-retryable entry that is no exception). The message names the parameter and the option.
When to build the stub yourself#
Inject stubs as arguments by default. Build one with $env->activityStub() in these cases, where
the attribute cannot express what you need:
- The class implements a workflow contract interface. PHP does not let the implementation add
required parameters to
run(), so the environment comes through the constructor and the stub is built from it. See the example below. - The options depend on the workflow’s input, such as a task queue named after a tenant. An
attribute argument is a constant, so build an
ActivityOptionsvalue object and pass it as the second argument ofactivityStub(). See ActivityOptions on the stub. - A signal or update method needs the stub. Durable calls those methods with the message’s payload only, so the stub comes from a property built in the constructor.
- A private helper method calls the activity. Pass it the injected stub as an argument, or keep the stub in a property built in the constructor.
- The workflow is a closure run by the test harness (
WorkflowTestEnvironment::run()): it receives the environment only. See Testing workflows. - The stub is a Nexus stub or a child workflow stub. Durable injects
WorkflowEnvironmentandActivityStubonly; build these with$env->nexusStub()and$env->childWorkflowStub().
The constructor form with a contract interface (optional, but useful for tests and typing):
/** Domain contract. No attributes required on the interface. */
interface OrderWorkflowContract
{
public function run(string $orderId): mixed;
}
#[AsWorkflow(name: 'order')]
final class OrderWorkflow implements OrderWorkflowContract
{
public function __construct(
private readonly WorkflowEnvironment $environment,
) {
}
#[AsWorkflowMethod]
public function run(string $orderId): mixed
{
$activities = $this->environment->activityStub(OrderActivities::class);
return $this->environment->await($activities->charge($orderId));
}
}ActivityOptions on the stub#
To apply retries, timeouts, task queue, and related scheduling metadata to every call
made through a given stub, give them to #[Activities] as scalars, as shown in
Arguments Durable supplies. When they depend on the workflow’s
input, pass an ActivityOptions value object as the second argument to activityStub():
use Gplanchat\Durable\Activity\ActivityOptions;
$options = ActivityOptions::of(5, 120, taskQueue: "payments-{$tenant}"); // 5 attempts, 120s each
$activities = $env->activityStub(OrderActivities::class, $options);More patterns are in Creating activities: ActivityOptions, and every option is described in Options and value objects.
Naming: ActivityStub vs ActivityInvoker#
The ADRs use the canonical term ActivityInvoker for this pattern. In the current package the
type is ActivityStub, returned by WorkflowEnvironment::activityStub(), in the same role:
typed calls that return an Awaitable. The stub delegates to a narrow scheduling port that a
workflow never receives, so workflow code cannot name an activity as a string.
Example: two entry methods#
If you expose two #[AsWorkflowMethod] methods on the same workflow type, DUR022 requires exactly one to set default: true on the attribute. When the attribute exposes that parameter in your version, the code looks like this:
#[AsWorkflowMethod]
public function runMain(Input $input): mixed { /* ... */ }
#[AsWorkflowMethod(default: true)] // illustrative, enable when supported by the attribute
public function runAlternate(Input $input): mixed { /* ... */ }Until default exists on #[AsWorkflowMethod], follow your runtime’s registration rules to choose the primary entry.
What you define#
- A workflow interface (optional contract) and/or a class annotated with
#[AsWorkflow](the attribute goes on the class with current loaders). It is the typed contract for registration and tests. - A concrete class registered with the runtime. If you wrote a contract interface, the class implements it (constructor form).
WorkflowEnvironmentand activity stubs only, as workflow method arguments or, with the constructor form, as its one parameterWorkflowEnvironment $environment. Do not inject services, repositories, or other application dependencies into the workflow class. Side effects go in activities.
Registry: alias and FQCN#
When you register a workflow class, the runtime indexes it under two strings: the name from #[AsWorkflow] (first argument), or the class short name if that attribute is missing, and the fully qualified class name (FQCN). WorkflowRegistry::getHandler() accepts either key for dispatch.
Temporal and the durable journal use the alias as the workflow type name, never the FQCN. WorkflowRunHandler and TemporalWorkflowStarter normalize WorkflowRunMessage payloads with WorkflowDefinitionLoader::aliasForTemporalInterop(): a FQCN you pass is resolved to the alias before ExecutionStarted is persisted and before the Temporal WorkflowType is set. Stored metadata uses the alias, as the server does.
Entry and optional handlers#
- Declare at least one method with
#[AsWorkflowMethod], your main durable entry (scenario start). - If you expose several
#[AsWorkflowMethod]methods on the same workflow type, exactly one must setdefault: trueto mark the primary entry for the runtime. - Optionally, add these handlers:
#[AsSignalMethod]takes external input that updates workflow state deterministically.#[AsQueryMethod]gives a read-only view of state (no durable side effects from the handler).#[AsUpdateMethod]carries validated updates with response semantics when supported. On Temporal, updates need Server 1.21 or newer, and a server setting before 1.25: see the Temporal prerequisites on the Backends page.
Parameters and return types must be serializable (see the serialization ADR DUR007).
WorkflowEnvironment#
The engine supplies WorkflowEnvironment as a workflow method argument, or to your constructor.
The table lists its whole surface, every operation available to a workflow. The operations the
engine keeps for itself are not on it.
await($awaitable, $deadline = null) |
The only wait. An elapsed deadline raises DeadlineExceededException and returns no value, so work that legitimately returns null stays distinguishable. |
all(...$awaitables) |
Settles when every member succeeds. One failure fails the whole. |
any(...$awaitables) |
Settles on the first member to settle; the losers are cancelled. |
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 for the duration, the same as await(timer($duration, $summary)). |
activityStub($contract, $options = null) |
A typed proxy over an activity contract. Build it in the constructor, or declare it as an #[Activities] argument, options included; 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. |
nexusStub($contract, $endpoint, $timeouts = null) |
A typed proxy over a Nexus contract served at $endpoint. Its calls return awaitables. See Nexus operations. |
nexusOperation($endpoint, $service, $operation, $payload = [], $timeouts = null) |
Calls one Nexus operation by its endpoint, service and operation names, and returns an awaitable. Throws NexusUnsupportedByBackendException on a backend that cannot route the call. |
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 instead of a wait that never settles. |
onUpdate($name, $handler) |
The same for an update, whose handler’s return value is the caller’s response. |
hasSignalHandler($name), hasUpdateHandler($name) |
Whether a handler is registered under that name, for code that registers one only once. |
sideEffect($closure) |
Runs non-deterministic local work once and journals its result, so replay reproduces it. |
version($changeId, $minSupported, $maxSupported) |
Declares a change point and returns the version this execution follows, between $minSupported and $maxSupported. The answer is fixed at the first encounter and read back from the journal afterwards. See Changing a workflow that is already running. |
continueAsNew($type, $payload = [], $options = null) |
Ends this run and starts the next with a fresh history. |
executionId() |
This execution’s identifier, an ExecutionId. Call toString() to put it in a payload or a log context: the object encodes to {} in JSON. |
Activities are only reachable through a stub. This surface has no way to name one as a string with a free-form payload. A typo there would produce an activity that is never scheduled, where a stub call gives an error that your IDE and your static analyser catch first.
Query, signal and update handlers are declared with #[AsQueryMethod], #[AsSignalMethod] and
#[AsUpdateMethod], and the engine wires them. Signals and updates can also be registered
imperatively, with onSignal() and onUpdate(). A workflow expressed as a closure has to use
them, since a closure cannot carry an attribute. Prefer the attribute, which a reader sees without
running anything.
Queries have no imperative form. They are read by the worker, outside the workflow’s fiber, so
their handlers live on the engine side and #[AsQueryMethod] is the only way to declare one. A
closure-shaped workflow cannot answer a query. A workflow that must answer one has to be a class.
WorkflowEnvironment::wrap($context, $runtime) builds an environment over an ExecutionContext
without the contract resolvers. Use it in a runner or a test harness of your own. Workflow code always
receives the environment the engine built.
You never instantiate activity implementations inside the workflow body.
Checklist#
| Rule | Detail |
|---|---|
| Constructor | None needed; with the constructor form, only WorkflowEnvironment |
| Contract | #[AsWorkflow] on the class; an interface is optional; a class that implements one uses the constructor form |
| Entry | At least one #[AsWorkflowMethod]; use default: true if multiple |
| I/O | None in the workflow; use activities |
| Calls to work | Through an ActivityStub, received as an #[Activities] argument or built from the environment |
finally |
Runs on every pass that suspends, not once per execution; keep it free of work |
finally runs on every pass that suspends#
Durable does not keep a waiting workflow in memory between passes. Each pass replays it up to the
next wait, then drops it, and PHP runs its finally blocks when it is dropped. Temporal’s Java and Go
SDKs do the same when they evict a cached workflow.
try {
return $env->await($this->payments->charge($order));
} finally {
$this->released = true; // runs on each pass that waits here
$this->inventory->release($order); // refused while the pass is being dropped
}- Plain code in a
finallyruns once per pass. Setting a field, or adding to a log kept in the workflow, happens again every time the workflow is resumed and waits again. - Work started from a
finallyfails while the pass is being dropped. An activity, a timer, a child or a side effect started then never reaches the journal, and anawaitthere does not wait. The pass ends as it would have without thefinally. - On the pass where the workflow really ends, by returning, failing or being cancelled, the
finallyruns as usual, and the work it starts is recorded.
Put cleanup that must happen once in a catch, or after the try, where the workflow reaches it
only when it really gets there. Compensation is written that way; see
Cancellation.
See also#
- Concepts covers workflow vs activity, replay and backends.
- Creating activities covers activity interfaces,
#[AsActivityMethod]andActivityInvoker.