Durable and the Temporal PHP SDK#
Temporal ships an official PHP SDK. Durable is not a fork
of it, not a wrapper around it, and does not depend on it: composer.lock contains no
temporal/sdk and no RoadRunner package. The two solve the same problem — durable execution of
long-running business logic — and they make different trade-offs at every layer below that.
This page states those differences, including the ones where the SDK is ahead.
Which SDK. Every claim below was checked against temporal/sdk v2.18, released 2026-08-17.
The SDK moves, and two of the differences stated here are ones its maintainers have said out loud
they intend to close — sections 5 and
8 name the public work in flight. Read a difference as of
that version, not as a permanent property.
1. The worker runtime: no RoadRunner#
The SDK splits into a client and a worker. The client needs ext-grpc; the worker needs
RoadRunner, a Go application server downloaded into the project with ./vendor/bin/rr get and
configured through its own .rr.yaml. Workflow and activity code runs inside PHP processes that
RoadRunner supervises.
Durable has no second runtime. A worker is an ordinary PHP CLI process, launched by the console the host already ships. What carries the work is the host’s own transport — Symfony Messenger, Laravel’s queue — or, on Magento, the backend queue the command polls itself:
bin/console messenger:consume durable_workflows durable_activities # Symfony
php artisan queue:work # Laravel
bin/magento durable:worker --role=journal # Magento
bin/magento durable:worker --role=activity # (two roles, two processes)| Durable | Temporal PHP SDK | |
|---|---|---|
| Worker process | messenger:consume, queue:work or bin/magento durable:worker, supervised by whatever already supervises your processes |
RoadRunner (Go binary), supervised by RoadRunner |
| Extra binary in the image | no | yes |
| Worker configuration | messenger.yaml, config/durable.php or di.xml |
.rr.yaml |
| Deployment model | the one your application already uses | a second process model to learn and operate |
What this does not claim#
Durable does not remove gRPC. When the backend is Temporal, the bridge speaks gRPC to the cluster
and ext-grpc is required — it is declared by gplanchat/durable-bridge-temporal, not by the
core package:
| Package | Requires |
|---|---|
gplanchat/durable |
php >= 8.2, psr/cache — nothing else |
gplanchat/durable-bridge-temporal |
ext-grpc, grpc/grpc, google/protobuf, symfony/messenger |
gplanchat/durable-bridge-dbal |
doctrine/dbal, symfony/lock, symfony/messenger |
gplanchat/durable-bridge-illuminate |
illuminate/database, illuminate/contracts |
gplanchat/durable-laravel |
illuminate/support, illuminate/container — no Symfony component |
So: no RoadRunner, ever; ext-grpc only when you talk to a Temporal cluster. On the in-memory,
DBAL and Illuminate backends, no PHP extension beyond a standard install is involved. The rule behind this is
recorded in DUR006.
2. Testability#
This is where the two libraries diverge most, and the divergence is structural rather than a matter of tooling: it follows from how a workflow reaches the engine.
With Durable, the workflow runs in the test process#
DurableTestCase wires the In-Memory backend and runs your production class:
final class GreetWorkflowTest extends DurableTestCase
{
public function testWorkflowGreetsCorrectly(): void
{
$greetSpy = ActivitySpy::returns('Hello, Alice!');
$env = $this->createWorkflowTestEnvironment(['greet' => $greetSpy]);
$result = $env->runWorkflowClass(GreetingWorkflow::class, ['name' => 'Alice'], 'exec-1');
self::assertSame('Hello, Alice!', $result);
$greetSpy->assertCalledWith(['name' => 'Alice']);
$this->assertWorkflowCompleted('exec-1', 'Hello, Alice!');
$this->assertActivityExecuted('exec-1', 'greet');
}
}No server, no binary, no extension, no Docker. See Testing workflows for the full toolkit.
With the SDK, every workflow test is an integration test#
The SDK’s test environment boots a Temporal test server and a RoadRunner worker from a PHPUnit bootstrap file:
// bootstrap.php
$environment = Temporal\Testing\Environment::create();
$environment->start();
register_shutdown_function(fn () => $environment->stop());The test then drives the workflow from outside, over gRPC, and observes it through the client:
$this->activityMocks->expectCompletion('SimpleActivity.doSomething', 'world');
$workflow = $this->workflowClient->newWorkflowStub(SimpleWorkflow::class);
$run = $this->workflowClient->start($workflow, 'hello');
$this->assertSame('world', $run->getResult('string'));The workflow never executes in the PHPUnit process. Activity mocks are an out-of-process channel:
the expectation is written on one side and read by the worker on the other. This is faithful — it
is a real Temporal server — but there is no cheaper tier below it. Asserting that a match in
your workflow picks the right branch costs two binaries and a gRPC round trip.
Why Durable can do this#
Three properties of the authoring surface, not three test helpers:
- The environment is injected, not static.
Workflow::newActivityStub()reads a static context bound to the running worker and throwsOutOfContextExceptionoutside it. A Durable workflow receivesWorkflowEnvironmentthrough its constructor, so it is an ordinary PHP object a test can build. There is no global state to reset between tests. - Fibers instead of generators. A workflow method returns its declared type. PHPUnit compares a value; it does not drive a generator or resolve a promise.
- The test runs the production class.
runWorkflowClass()goes through the same constructor, the same attributes, the same#[AsWorkflowMethod]— see DUR039.
What you can assert#
Durable exposes the event journal to the test, not just the return value:
DurableTestCase |
ActivitySpy |
|---|---|
assertWorkflowCompleted() |
ActivitySpy::returns() / throws() / returnsSequence() |
assertWorkflowFailed($failureClass) |
assertCalledWith() / assertFirstCallWith() |
assertActivityExecuted() |
assertCalledTimes() / assertCalledOnce() / assertNotCalled() |
assertEventStoreContains($eventClass) |
calls() / callCount() |
countActivityExecutions() |
countActivityExecutions() is the one to keep in mind: it proves an activity was not re-run
after a retry. A black-box assertion on the result cannot see that.
For Symfony integration tests, DurableBundleTestTrait does the same inside KernelTestCase,
draining the Messenger transports until the run settles.
The tier that does need a server#
Durable’s own integration suite runs against a real Temporal server — ext-grpc, a running
temporal server start-dev, and PHP worker processes spawned by the test case:
temporal server start-dev --namespace durable-test --port 7233
DURABLE_TEMPORAL_ADDRESS=127.0.0.1:7233 vendor/bin/phpunit --testsuite integrationThe suite is skipped when DURABLE_TEMPORAL_ADDRESS is unset.
The difference from the SDK is not “no server” — it is which tests need one. This tier exists to prove that the bridge’s commands are accepted by a real server: round trips, failure paths, deadlines, updates, cron schedules, search attributes, Nexus. It is deliberately narrow, and it is about the bridge, not about your business logic. Your workflows are covered by the unit tier, which needs nothing. With the SDK, the server-backed tier is the only tier there is.
| Durable | Temporal PHP SDK | |
|---|---|---|
| Unit tier (business logic) | PHPUnit, in-process, zero infrastructure | none — every workflow test is out-of-process |
| Server-backed tier | optional, scoped to wire and protocol parity | mandatory, for all workflow tests |
| What it needs | a Temporal dev server + ext-grpc |
test server + RoadRunner |
| Runs in CI without Docker | the unit tier does | no |
The honest cost#
A passing In-Memory test does not prove Temporal behaves the same. That risk is real, and it is managed rather than denied:
- DUR018 requires event and slot parity between In-Memory and Temporal;
- DUR016 bounds what an In-Memory implementation may simplify, and requires each shortcut to justify itself in a docblock;
- the integration tier above is what actually checks it.
Time skipping is not among the things you give up. The In-Memory runner holds a virtual clock
and advances it to the next timer’s due date, so sleep(3600) settles in a millisecond of real
time. It only skips when nothing else can progress — skipping while an activity could still
complete would make the timer win every any(activity, timer) race. See
Testing workflows.
3. Backends: one, or four#
| Durable | Temporal PHP SDK | |
|---|---|---|
| Execution backends | four, running the same workflow code | a Temporal cluster |
| Tests | In-Memory, no server | test server |
| Production without a cluster | DBAL or Illuminate — durable execution on one SQL database | not possible |
In-Memory, plus the three bridges you choose between: Temporal, DBAL or Illuminate.
The two SQL backends (DUR030
on Doctrine’s connection, DUR047
on Laravel’s) have no counterpart in the SDK: a journal, workflow metadata and locks on a single
relational database, no cluster and no ext-grpc. For an application that needs durable execution
but not the operational surface of a Temporal deployment, this is often the deciding difference —
more than the worker runtime.
Switching is a configuration change, and the knob depends on the host: on Symfony
durable.event_store.type takes three of the four (memory, dbal, temporal), and Illuminate is
bound instead by gplanchat/durable-laravel through its own config/durable.php. Either way the
workflow code does not move. See Backends.
4. The authoring surface#
The same workflow — charge an order, wait an hour, send the receipt — written twice.
Durable — injected environment, fibers, plain return types:
#[AsWorkflow(name: 'order')]
final class OrderWorkflow implements OrderWorkflowContract
{
public function __construct(
private readonly WorkflowEnvironment $environment,
) {
}
#[AsWorkflowMethod]
public function run(string $orderId): string
{
$activities = $this->environment->activityStub(OrderActivities::class);
$charge = $this->environment->await($activities->charge($orderId));
$this->environment->sleep(Duration::hours(1));
return $this->environment->await($activities->sendReceipt($charge));
}
}Temporal PHP SDK — static facade, generators, promises:
#[WorkflowInterface]
interface OrderWorkflowContract
{
#[AsWorkflowMethod]
public function run(string $orderId);
}
final class OrderWorkflow implements OrderWorkflowContract
{
public function run(string $orderId)
{
$activities = Workflow::newActivityStub(OrderActivities::class);
$charge = yield $activities->charge($orderId);
yield Workflow::timer(3600);
return yield $activities->sendReceipt($charge);
}
}Same steps, same names, same order. What differs is everything around them:
| Durable | Temporal PHP SDK | |
|---|---|---|
| Access to the engine | WorkflowEnvironment injected in the constructor |
static Workflow:: facade |
| Suspension | fibers + Awaitable |
yield + React\Promise\PromiseInterface |
| Function colouring | ordinary methods, declared return types | any awaiting method becomes a generator, and so does its caller — see below |
| Declaration | #[AsWorkflow] on the class |
#[WorkflowInterface] on an interface, implemented by a class |
| Method attributes | #[AsWorkflowMethod], #[AsSignalMethod], #[AsQueryMethod], #[AsUpdateMethod] |
the same four, workflow updates included |
The return type is the visible consequence: run() declares string on one side; on the other, the
only type it could declare is \Generator, which says nothing about what the workflow returns. That
is what makes the Durable class an ordinary object a PHPUnit test can build and call — see
Testability.
The attribute vocabulary is deliberately close; the execution model underneath is not.
5. Fibers or generators: the colouring problem#
The function colouring row above is the mechanism under Testability — it is the second of the three properties listed there, and it is worth its own section. The name comes from Bob Nystrom’s What Color Is Your Function?: in a language where suspension is a keyword, functions come in two colours — red suspends, blue does not — and a red one can only be called from another red one.
yield is that keyword. A method that yields is a generator: it no longer returns its value, it
returns a Generator that somebody has to drive. Extract three lines of a workflow into a helper —
the ordinary refactoring — and if those lines await, the helper turns red, and every caller up to
the workflow method turns red with it.
Durable — the helper is an ordinary method:
#[AsWorkflowMethod]
public function run(string $orderId): string
{
return $this->chargeWithRetry($orderId);
}
private function chargeWithRetry(string $orderId): string
{
foreach ([1, 2, 4] as $backoff) {
try {
return $this->environment->await($this->activities->charge($orderId));
} catch (DurableActivityFailedException) {
$this->environment->sleep(Duration::seconds($backoff));
}
}
throw new ChargeGaveUp($orderId);
}Temporal PHP SDK — the helper is a generator, and so is its caller:
public function run(string $orderId)
{
return yield from $this->chargeWithRetry($orderId);
}
private function chargeWithRetry(string $orderId)
{
foreach ([1, 2, 4] as $backoff) {
try {
return yield $this->activities->charge($orderId);
} catch (ActivityFailure) {
yield Workflow::timer($backoff);
}
}
throw new ChargeGaveUp($orderId);
}A retry policy would normally do this for you — ActivityOptions carries one on both sides, and
Failures and retries is where it belongs. What the example is about is the
extraction: three lines moved out of a workflow method into a helper. Two return types
disappear, and the call site changes to yield from. Neither is a detail — they are what the
colour costs.
Durable suspends with \Fiber::suspend(), and it does so inside the runtime, in
ExecutionRuntime::await(), several frames below your code. A fiber suspends the whole call stack,
not the frame that asked: the frames in between are suspended without participating, so they need
no keyword, no return type change, and no rewrite.
| Durable (fibers) | Temporal PHP SDK (generators) | |
|---|---|---|
| Awaiting from a helper method | ordinary private method | the helper becomes a generator |
| Its callers | unchanged | every one of them becomes a generator too, up to #[AsWorkflowMethod] |
| The call site | $this->chargeWithRetry($id) |
yield from $this->chargeWithRetry($id) |
| Declared return type | the method’s own — string |
none it can usefully declare |
| Calling it from outside a workflow | an ordinary call | needs something to drive the generator |
That last row is what Testability rests on: a blue workflow is an object PHPUnit builds and calls.
What the colour buys, and what it costs to give up#
Colouring is not only a tax. yield marks the suspension point in the source — reading the
method, you know exactly where the workflow can stop for a week. Fibers take that marker away: an
ordinary-looking call may suspend and nothing at the call site says so.
Durable narrows the loss rather than denying it. Only await() waits, and sleep(), which is
await() on a timer written short — every stub call, timer(), all(), any() and some()
assembles and returns immediately. Inside a given method, the waiting points are exactly those
calls. What a reader cannot see is whether a helper waits inside, which is the price of the
refactoring the SDK forbids.
Two limits worth knowing:
- fibers are PHP 8.1+; Durable requires 8.2 regardless;
- a fiber cannot suspend in a destructor — PHP throws
FiberError: Cannot switch fibers in current execution context. Awaiting from__destruct()is not workflow code, so this has not come up in practice, but it is the one context where the stack is not free to suspend.
Neither model affects determinism: both replay the same history, and both forbid the same non-deterministic calls inside a workflow. The difference is where the suspension keyword lives — in your code, or in the runtime.
The SDK intends to close this#
Fibers are not a permanent divide. The SDK has an open pull request adding a Fibers API (#798), on top of the issue that proposed replacing yields with fiber suspension (#702), and its maintainers have said the change is prototyped and slated for an upcoming major. None of it is in a release as of v2.18, and this section describes v2.18.
What that would settle is the colouring, and only the colouring. The suspension mechanism is not
what makes a workflow test need a server — the worker runtime
is. A workflow still runs inside RoadRunner, driven by a task queue on a real cluster, whether it
suspends on a yield or on a fiber. So the difference that would matter most once fibers land is
the one above them: Testability — running a workflow to completion in the test
process, asserting on a returned value, with no server to start and no second runtime to supervise.
6. Scheduling activities#
The SDK accepts both a typed stub and a call by activity name with a free-form payload. Durable
removed the second form: the typed stub is the only way a workflow schedules an activity
(DUR039),
and the optional gplanchat/durable-phpstan extension resolves stub calls against the contract
interface so a wrong argument is a static analysis error rather than a serialization failure at
runtime.
Less freedom, one class of mistakes removed at analysis time. See Creating activities.
7. Workflow versioning#
Both let one class carry two behaviours and let history decide which a run sees:
// Temporal PHP SDK
$v = yield Workflow::getVersion('add-discount', Workflow::DEFAULT_VERSION, 1);
// Durable
$v = $this->environment->version('add-discount', ChangePoint::DEFAULT_VERSION, 1);The wire format is the same one, and not by imitation — it was read off a history the Go SDK
produced, then emitted from the bridge and accepted by the server. A versioned Durable execution and
a versioned Go execution record the identical Version marker and the identical
TemporalChangeVersion search attribute, so both come back from the same query when you ask who is
still on an old branch.
Two differences, and neither is about the primitive:
| Worker versioning | Build ids, deployment names, pinning a run to a worker version — the operational mechanism that lives in the worker and the task queue rather than in workflow code. The SDK has it; Durable does not. |
| Knowing when a branch is dead | A query on the Temporal backend, for both. On Durable’s journal backends there are no search attributes, so the question has no equivalent answer. |
See Changing a running workflow.
8. Nexus: the one place Durable is ahead#
Nexus routes a call from a workflow to an operation served in another namespace or another cluster. A Durable workflow can call one, and can serve one. A workflow written with the official PHP SDK can do neither.
$checkout = $env->nexusStub(CheckoutContract::class, endpoint: 'checkout-endpoint');
$order = $env->await($checkout->placeOrder($cartId));The contract is written once and read from both sides, so no operation name is retyped as a string. That matters because the server only guards the endpoint: it refuses a malformed one outright, and accepts an empty or whitespace-only service or operation without a word — leaving the call waiting for a handler whose name will never match.
As of v2.18, “Nexus” appears in the PHP SDK only as generated gRPC plumbing — endpoint CRUD on the operator client, a task-slot option on the worker, history dumping — with no API a workflow can reach. Temporal’s own documentation carries a Nexus section for Go, Java, Python, TypeScript and .NET, and none for PHP.
This one is being built. An integration is open in a pull request (#768), following the issue that opened the subject (#580), and its maintainers have said it is slated for an upcoming major. Read “the one place Durable is ahead” as a lead measured in releases, not as a gap that will stay open.
On the Durable side the caller path is exercised by integration tests against a real Temporal server: round trips, cancellation and failure, operation bounds, the endpoint, service, operation and header naming rules — and, on the handler side, both response shapes and the cancellation path, a Durable caller and a Durable handler in the same test.
And the call interoperates. The payload travels as the caller wrote it — no wrapper, no
envelope — so a handler written with another SDK reads the fields it declares. Measured against a
handler served by the Go SDK, which declares Greeting{Name string}, receives {"name":"ada"}
and answers hello ada. The reverse was measured too: a Go caller invoking an operation served by
Durable gets its own declared type back, and the two histories are identical event for event.
Serving, too#
A handler declares the operation it serves, and answers now or later:
#[AsNexusServiceHandler(contract: BillingServed::class)]
final class Billing implements BillingServed
{
// Now, if you already have the answer — you have about nine seconds.
public function verify(Order $order): Verdict { /* … */ }
}
// Later, for anything real: a workflow claims the operation and produces the result.
#[AsWorkflow]
#[FulfilsNexusOperation(BillingContract::class, 'charge')]
final class Charge { /* … */ }The nine seconds are not a Durable limit but the task’s own request-timeout, measured: a handler
still working when it expires has its task redelivered and starts over. That budget is exactly why
the deferred form exists, and why it was built before the immediate one.
Cancellation needs no hook: Durable cancels the workflow fulfilling the operation, and a workflow already observes its own cancellation with its compensations.
See Nexus operations for the whole surface.
What this means for PHP. No other PHP implementation serves Nexus, because no other PHP implementation reaches Nexus at all. Until now, a PHP service could not be a Nexus provider: a team running PHP was reachable over HTTP like any other service, but not through the boundary Temporal gives to Go, Java, Python, TypeScript and .NET — no durable operation, no server-side correlation, no cancellation that follows the call. Durable puts PHP on both sides of that boundary.
One limit, and it is deliberate:
- Temporal backend only. Nexus routes to an endpoint served elsewhere; a backend keeping its
journal in one database has no such route and no honest fallback. The DBAL backend therefore
refuses immediately with
NexusUnsupportedByBackendException, which names the backend and what to do instead, rather than leaving the workflow waiting on a result nobody will produce. On the handler side the same refusal fires when the container is built, not at request time — a handler with no route is not a call that fails, it is a service that never receives anything.
The reasoning is recorded in DUR036 and DUR045.
9. Where the SDK is ahead#
| Maintenance | Official Temporal project, kept in parity with the other language SDKs |
| Maturity | Long production track record. Durable is 0.1.0-alpha, with breaking changes between alphas |
| Saga | A dedicated helper. Durable has none — the shape is a deadline and a compensation path, written out in Creating a workflow, so what is missing is the sugar rather than the capability |
| API coverage | Broad. Durable covers search attributes, cron schedules, updates, deadlines and child workflows — but search attributes are start options here, where the SDK also lets a running workflow upsert its own; anything beyond that is worth checking against the Configuration reference before you commit |
A comparison with no losses column is marketing. These are real — and maturity is the one that
weighs most: 0.1.0-alpha means breaking changes between versions, each shipped with its migration
procedure, but breaking changes all the same.
Choosing#
Use the Temporal PHP SDK when you already operate a Temporal cluster, want the officially maintained client with cross-language parity, need worker versioning — build ids, pinning a run to a worker version — or a Nexus handler, and RoadRunner is acceptable in your deployment.
Coming from the SDK? gplanchat/durable-rector does the mechanical part: the attributes and
the failure classes, keeping the workflow and activity type names a running server already knows
— the part a hand migration silently gets wrong — and the execution model, where the static
Workflow:: facade becomes an injected environment and yield goes, along with the \Generator
return type it leaves behind. What it will not do is invent the return type that replaces it, or
convert what has no counterpart here: those it comments, so you know before you start whether the
migration is open to you at all.
Use Durable when you want durable execution without adding a second runtime to your application, when a single SQL database is the right operational footprint, when you want workflow logic covered by unit tests that need no infrastructure, or when you need to call Nexus operations from PHP at all — and when an alpha with breaking changes between releases is a trade you can make.
See also#
- Packages — what each package contains and what it requires.
- Backends — In-Memory, DBAL, Illuminate and Temporal side by side.
- Testing workflows — the full testing toolkit.
- Creating a workflow — the authoring surface in detail.