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 throws OutOfContextException outside it. A Durable workflow receives WorkflowEnvironment through 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 serverext-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 integration

The 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#