Durable durable.rocks
Durable execution for PHP

Long jobs that survive
crashes, deploys and restarts.

Charge the card, reserve the stock, send the receipt, in one method. If the process dies halfway through, another process continues from the step where it stopped.

Get started Install now Install AI Skill

A payment is charged, then the process must reserve stock, then email the customer. The worker is redeployed between the second and third step. What happened to the order?

Without Durable, the same redeploy
charge The job is retried from the top. The customer is charged a second time.
reserve-stock Held for an order that will never ship, or dropped, and the item is oversold.
send-receipt Never runs. The customer gets no email.

You find out from a support ticket three days later. The fix is a repair script, written by hand and run in production.

With Durable, the same redeploy
charge Returns from the journal. The card is not charged again.
reserve-stock Returns from the journal. The stock is not reserved again.
send-receipt Runs once, after the restart. The email goes out.

The order completes. The charge is not replayed and the reservation is kept, and the email goes out.

The journal of this order
01
10:02:11
charge
Recorded in the journal with its result.
02
10:02:13
reserve-stock
Recorded. A replay reads it back instead of reserving again.
⟳
10:04:57 · interruption
deploy v412
The worker stops mid-process. The method is replayed on the new worker. Steps 01 and 02 return from the journal and do not run again.
03
10:07:11
send-receipt ✓
Runs for the first time, five minutes after the charge, as written.

No completed step ran twice and no step was lost. No cron job took part in this run.

The workflow is one PHP method

On every resume the code re-runs from the start, but each step already recorded returns its recorded result instead of running again.

You declare no state machine and no transition table, because the process is the method. The environment is injected in the constructor, and activities are typed stubs, so your IDE and your static analyser already understand every call the workflow makes.

A worker can be redeployed mid-process and the execution continues where it stopped.

src/Workflow/CheckoutWorkflow.php PHP 8.2+ · hover a line
#[Workflow(name: 'checkout')]
final class CheckoutWorkflow
{
private ActivityStub $orders;
 
public function __construct(
private readonly WorkflowEnvironment $env,
) {
$this->orders = $env->activityStub(OrderActivities::class);
}
 
#[WorkflowMethod]
public function run(string $orderId): string
{
$payment = $this->env->await($this->orders->charge($orderId));
$this->env->await($this->orders->reserveStock($orderId));
$this->env->sleep(Duration::minutes(5));
$this->env->await($this->orders->sendReceipt($payment));
 
return $payment;
}
}

What you no longer write

Five parts you maintain today, and what replaces each one.

What you delete What replaces it
−A state column, and the migration that adds the next state +The line the method is on. The journal holds the position.
−A cron that scans rows in pending_* +The next statement. Timers and signals wake the execution.
−A retry counter and the dead-letter table +RetryLimit::ofAttempts(3), an option on the stub.
−A guard against the job that ran twice +A recorded step returns its recorded result and does not run again.
−A way to know why order 4242 stopped three days ago +Replay its journal: every step, every result, every attempt.

For this order, five files and a runbook become one method and its journal.

Six more patterns in the same workflow class

Every excerpt below is the same class as above: the environment injected in the constructor, activities as typed stubs. Each one has its own page.

use case 01

Run two steps at once, and keep both results

Calling an activity returns an awaitable. all() combines two of them into one, and the execution waits at await().

Parallelism →
two steps, one waitboth results, in order
[$stock, $credit] = $this->env->await($this->env->all(
    $this->orders->reserveStock($orderId),
    $this->orders->checkCredit($orderId),
));
use case 02

Bound a wait in time, without writing the race

The deadline is the second argument to await(). When the deadline elapses, you get a failure instead of an ambiguous null. To wait for the clock alone, call sleep().

Deadlines and timeouts →
a clock on the awaitan elapsed deadline is a failure
try {
    $quote = $this->env->await(
        $this->orders->callProvider($orderId),
        deadline: Duration::seconds(30),
    );
} catch (DeadlineExceededException $e) {
    $quote = $this->fallbackQuote($orderId);
}

$this->env->sleep(Duration::minutes(5));   // just wait, awaits for you
use case 03

Stop waiting once enough answers are in

Three quotes out of eight are enough to decide; the other five cost only their latency. Results come back indexed by declaration position, so every price still points at the provider that gave it.

Quorum with some() →
the first three answers winresults indexed by declaration position
$prices = $this->env->await($this->env->some(3, ...$providers), deadline: Duration::seconds(2));
use case 04

Declare what a failure means, once

Retry limits, non-retryable exceptions and per-attempt timeouts are options on the activity stub. You no longer spread a counter, a dead-letter table and a runbook across your codebase.

Retries and failure policy →
options on the stubdeclared once, in the constructor
$this->orders = $env->activityStub(
    OrderActivities::class,
    ActivityOptions::of(
        retryLimit: 3,
        timeouts: Duration::seconds(30),
        nonRetryableExceptions: [PaymentRefusedException::class],
    ),
);
use case 05

Send input to a running process, and read its state

A signal delivers input to an execution already in flight; a query reads its state without disturbing it; an update is validated and answers back. Approval steps and manual holds need no separate infrastructure.

Signals, queries and updates →
signal, query, updatesent to a running execution
#[AsSignalMethod(OrderSignal::Approve)]
public function approve(string $approver): void { /* … */ }

#[AsQueryMethod('status')]
public function status(): string
{
    return $this->status;
}

#[AsUpdateMethod('changeAddress')]
public function changeAddress(string $address): string { /* … */ }

// inside the workflow method: a handler mutates state, a condition observes it:
$this->env->await(fn(): bool => null !== $this->approver, deadline: Duration::hours(1));
use case 06

Undo what already happened when a process is cancelled

Cancellation arrives inside the workflow as a catchable failure, so the process can compensate, refunding the charge and releasing the stock, and then stop.

Cancellation and compensation →
cancellation you can catchcompensate, then let it through
try {
    return $this->env->await($this->orders->charge($orderId));
} catch (WorkflowCancelledFailure $e) {
    $this->env->await($this->orders->refund($orderId));

    throw $e;
}
in practice 01

A process that lasts a month, in one method

An order goes to a supplier. The supplier has 48 hours to confirm, or it escalates. Once confirmed, delivery is awaited for up to 30 days, with reminders at day 7 and day 21. If nothing arrives, the payment is refunded. The whole workflow is below.

one method · 48 hours, then 30 daysno status column, no cron tick
// #[AsSignalMethod(SupplierSignal::Confirmed)] sets $this->confirmed
try {
    $this->env->await(fn(): bool => $this->confirmed, deadline: Duration::hours(48));
} catch (DeadlineExceededException $e) {
    $this->env->await($this->desk->escalate($orderId));
}

$this->env->sleep(Duration::of(new \DateInterval('P7D')));
$this->env->await($this->mail->remind($orderId));

// #[AsSignalMethod(SupplierSignal::Delivered)] sets $this->delivery
$this->env->await(
    fn(): bool => null !== $this->delivery,
    deadline: Duration::of(new \DateInterval('P30D')),
);
01

Racing a human against a clock

In Messenger, “confirm within 48h or escalate” needs two things in flight, a delayed message and a webhook, plus a way to know which won and to cancel the loser. The hard case is the edge. The supplier confirms at 47h59, the deadline message fires anyway, and you have to settle which one counts. Here the deadline is a typed failure rather than a value, and that makes the decision expressible in the method.

02

Waiting costs nothing

Thirty days in a queue is a message parked in a broker that has its own TTLs and redeliveries. Thirty days in cron is a row scanned every tick for a month. Here the execution does not run at all during the wait, and the next line runs when the wait ends.

03

Local variables die at the end of a handler

Everything computed in step one must be persisted to exist in step four, so the process becomes a status column plus a context table, and every new step is a migration. In one method, $delivery is just a variable.

04

“Why has order 4242 been stuck for three days?”

With cron or handlers you reconstruct the answer from logs. Here you replay the journal: every step, every result, every attempt, in order.

What Messenger and cron can already do

Messenger and cron can do all of this. You build it with status columns, locks and guards against double processing, and you watch over them. This comparison counts the code you write and keep.

in practice 02

Orchestrating an AI agent, with Symfony AI

An agent is a loop of non-deterministic decisions that cause irreversible effects. Replay makes that combination dangerous, and the journal resolves it.

a tool, and the turn around itthe model call is an activity
#[AsTool('issue_refund', 'Refunds an order, in cents')]
final class IssueRefund
{
    public function __invoke(int $orderId, int $amount): string { /* … */ }
}

// In the workflow: the model call is an activity, never inline:
// it answers differently every run, and replay must see the same answer.
$plan = $this->env->await($this->agent->decide($conversation));

if ($plan->needsApproval()) {
    $this->env->await(fn(): bool => $this->approved, deadline: Duration::hours(24));
}

$outcome = $this->env->await($this->tools->run($plan->tool(), $plan->arguments()));
01

The model does not answer the same thing twice

Under replay the workflow must see the same answer. Otherwise it takes a different branch and no longer matches its history. That is why the model call is an activity and does not run inline. An agent can be resumed only under that condition.

02

A tool call is a side effect

issue_refund refunds for real. If the worker restarts mid-loop the tool must not run again; it returns its recorded result. It is the same guarantee as for any activity; here the model chose the call instead of the developer.

03

Human in the loop

“I am about to refund €4,200. Confirm?” is one bounded wait that survives deploys. In a handler you cannot await; you store state and let a webhook find it again.

Where Durable stops

Durable does not orchestrate Symfony AI’s inner loop; Agent::call() runs its own tool round-trips. Durable bounds each turn: one decision, its effects, an optional approval, then the next turn.

Nexus · works today

Your PHP service can be on both ends of a Nexus operation

Nexus routes a call from one workflow to an operation owned by another team, another namespace, another cluster, with neither side knowing the other's workflows. Durable calls those operations, and serves them.

Temporal documents Nexus for Go, Java, Python, TypeScript and .NET. It documents none for PHP. A PHP service can be called over HTTP, like any other service, but an HTTP call is not an operation. No record says it was made, its failure does not come back typed, and it is not cancelled when the caller gives up.

Without it, the boundary is an HTTP call

Checkout calls Billing and waits. The connection gives up at thirty seconds; the charge takes four minutes. So Checkout retries, and Billing charges twice. Both teams then write the same three things by hand, each on their own side: an idempotency key, a reconciliation job, and a status column that means maybe.

With it, the same call, made durable

The caller waits without holding anything open, for four minutes or for six hours alike. A failure comes back typed, so you can write the compensation that matches it. Cancelling the caller cancels the work on the other side. And neither team ever sees the other's workflows.

Calling
$billing = $env->nexusStub(
    BillingContract::class,
    endpoint: 'payments',
);

$receipt = $env->await(
    $billing->charge($order, 1200),
);
Serving
#[AsNexusServiceHandler(
    contract: BillingServed::class,
)]
final class Billing implements BillingServed
{
    public function verify(Order $o): Verdict
    { /* … */ }
}

Both sides use one contract, written once and typed, and no operation name is retyped as a string. The operation can answer immediately or let a workflow answer hours later. The caller waits the same way in both cases, and cancelling the call cancels the workflow that carries it.

Calling and serving Nexus operations, end to end →

Change the backend without changing the workflow

A core that needs only psr/cache, an optional framework integration, and interchangeable backends: in-memory for tests, Temporal or SQL for production. Switching backends changes the install command and leaves the workflow code as it is.

gplanchat/durable

The library. Workflows, activities, timers, event journal. Exactly one runtime dependency: psr/cache. No framework.

required gplanchat/durable-bundle

The Symfony integration. Autoconfigures workflows and activities, wires Messenger transports and adds a profiler panel. Symfony 6.4, 7 or 8.

optional gplanchat/durable-plugin

The Sylius admin dashboard for tracking durable workflows. Registers an entry in the Sylius admin menu. It displays workflows and does not run them; it pulls the bundle in and leaves the backend choice to you.

optional gplanchat/durable-bridge-temporal

The Temporal driver. Speaks gRPC to a Temporal cluster directly; no official PHP SDK, no RoadRunner. Requires ext-grpc.

one of three bridges gplanchat/durable-bridge-dbal

The SQL backend. Durable execution on a single database, no orchestrator cluster. Doctrine DBAL 3 or 4.

one of three bridges gplanchat/durable-bridge-illuminate

The SQL backend through Laravel's own database layer, on a connection of its own. Illuminate 11, 12 or 13.

one of three bridges gplanchat/durable-laravel

The Laravel integration. A service provider that binds the four storage ports from one published config file, workflows declared rather than scanned, and work riding the queue the application already drains.

optional gplanchat/durable-filament

The Filament dashboard for tracking durable workflows. A panel plugin, registered with ->plugin(DurableFilamentPlugin::make()), that adds a Durable runs entry to the navigation. It displays workflows and does not run them; it pulls the Laravel integration in and leaves the backend choice to you. Filament 3 or 4. Install and register it →

optional API Platform integration

A state processor that returns a workflow handle and a 202 + Location instead of holding the request open for the work; one class, running on Symfony and on Laravel alike.

bundle today · plugin planned gplanchat/durable-magento

The Magento 2.4 / Mage-OS module. Declares workflows, activities and the Nexus handlers it serves through di.xml, ships the workers as bin/magento commands, and adds a read-only admin screen with a per-action timeline.

optional

What a long-running process needs

Workflows are unit-testable with no server at all, and the event journal can be replayed and inspected after the fact.

activities with retry limits timeouts timers side effects signals queries updates child workflows catchable cancellation cron schedules search attributes replayable event journal
The reference for these packages: what each one brings, what it needs, and the commands by situation →

Install Durable in your project

Open source, PHP 8.2+, no server needed to write your first workflow.

Your install command, in 2 questions Symfony · Temporal
1 What does your project run on?
More…coming soon

Symfony 6.4, 7 or 8. Autoconfiguration, Messenger transports, profiler panel.

↳ On which base?

2 Where does the state live?

The dashboard only reads runs, so any backend above works.

3 Copy the command
$ composer require gplanchat/durable-bundle gplanchat/durable-bridge-temporal

Durable is on its beta line. First run composer config minimum-stability beta and composer config prefer-stable true. Otherwise the install fails, because Composer's default minimum stability is stable.

Everything it depends on comes transitively, so the command names one package only.

No persistence between requests: this is the one option that does not go to production.

Every package, what each one brings, what it needs, and these same commands laid out by situation →
Documentation Source on GitHub
or let an agent write it

Install the Claude Code skill and let the agent write the workflow

The skill installs with two Claude Code commands. It teaches the three shapes, the determinism rule and a Rector migration for code that already exists. The migration rewrites what has a Durable counterpart and marks unsupported Workflow:: calls with a durable-rector: comment. It leaves some cases unchanged without a marker, and the package README lists them. The skill then helps you finish five cases, described in its migrations reference.

$/plugin marketplace add gplanchat/durable-skill
$/plugin install durable@durable-skill
Four mistakes that raise no error, covered by the skill
  • an activity name written as a string, not read from its contract
  • a timer with no summary, which you cannot find among ten thousand executions
  • a Nexus parameter renamed on one side only, so the other side gets null with no error
  • an execution started inline in a web request, which ends when the request ends