Concepts#

This page introduces the vocabulary and the model behind Durable. Read it before the hands-on guides. The glossary defines each term in one paragraph.


Durable execution#

Durable execution means that a long-running process, one that may span seconds, hours, or days, survives restarts, crashes, and deployments.

If you have written async/await in JavaScript, start from there. A JavaScript function that uses await has to be async, and so do its callers: the colour climbs the call stack (Bob Nystrom, What Color Is Your Function?). In a Durable workflow, $env->await() suspends the execution with a PHP Fiber, inside the runtime, so a method that waits stays an ordinary method with its own return type. The comparison with the Temporal PHP SDK covers this colouring problem in detail.

What JavaScript does not do is keep the wait when the process stops. The runtime records every decision (activity result, timer expiry, signal received) in a history. After a restart, it replays that history to restore the exact state the program was in: the method runs again, and each step already recorded returns its result. The analogy with JavaScript stops there. A replayed method has to take the same decisions on every pass, which is the determinism rule.

In your code, this reads like ordinary sequential PHP: await an activity, receive the result, continue. The runtime handles fault tolerance.


Workflow#

A workflow is pure orchestration logic. It:

  • Schedules activities (side-effect-bearing work) and awaits their results.
  • Sets timers (wait for a duration or until a point in time).
  • Reacts to signals (external one-way messages) and updates (messages with a response).
  • Exposes read-only queries to inspect state without changing it.
  • Spawns child workflows for sub-processes.

A workflow function must be deterministic: given the same history, re-executing it must produce the same sequence of commands. This is what makes replay possible.

What does not belong in a workflow:

  • HTTP calls, database queries, random numbers, timestamps: all non-deterministic.
  • Filesystem access, environment variable reads.
  • Any I/O that would produce a different result on replay.

All of that belongs in activities.


Activity#

An activity is the unit of potentially-failing, non-deterministic work. Activities:

  • Perform I/O: HTTP calls, database writes, emails, etc.
  • Use dependency injection (repositories, HTTP clients, loggers).
  • Are retried automatically on failure according to ActivityOptions.
  • Have their result recorded once; on replay, the result is taken from history without re-executing the activity.

A workflow interacts with activities through an ActivityInvoker (typed stub obtained from WorkflowEnvironment::activityStub()). Calling a method on the stub returns an Awaitable; await() suspends the workflow until the activity completes.


Event history and replay#

The runtime stores every meaningful event in a history (also called a journal):

ExecutionStarted
  └─ ActivityScheduled(name: "charge-order", attempt: 1)
       └─ ActivityCompleted(name: "charge-order", result: "ok")
            └─ ExecutionCompleted(result: "done")

When the workflow process restarts, or when Temporal schedules a new workflow task, the runtime replays the workflow function against this history:

Replay step 1: await activity("charge-order")
  → history has ActivityCompleted for this step → return "ok" immediately (no real HTTP call)
Replay step 2: return "done"
  → history has ExecutionCompleted → workflow is finished

Because the result is in history, the activity handler is not called again during replay. The function continues from where it left off.

Event types#

Event When it is recorded
ExecutionStarted Workflow accepted by the orchestrator
ActivityScheduled Workflow scheduled an activity
ActivityCompleted Activity returned a result
ActivityFailed Activity threw an unhandled exception
TimerStarted Workflow set a timer
TimerFired Timer elapsed
SignalReceived External signal delivered to the workflow
UpdateAccepted Transactional update accepted
ChildWorkflowStarted Child workflow dispatched
ChildWorkflowCompleted Child workflow finished
ExecutionCompleted Workflow returned a result
WorkflowExecutionFailed Workflow threw an unhandled exception

Await and Awaitables#

WorkflowEnvironment::await() is the single primitive for suspending the workflow. It accepts an Awaitable, a lightweight placeholder for a future result, and blocks (conceptually) until that result is available.

Under the hood, Durable uses PHP Fibers to suspend execution without blocking the OS thread. The fiber resumes when the orchestrator delivers the awaited result in a subsequent workflow task.

Fiber runs → hits await(activity) → no result in history → fiber suspends
                   ↓
         Temporal schedules new task with ActivityCompleted
                   ↓
         Fiber resumes from await → receives result → continues

You can compose awaitables:

// Sequential
$a = $env->await($activities->stepA());
$b = $env->await($activities->stepB($a));

// Parallel (both start at once)
[$a, $b] = $env->await($env->all(
    $activities->stepA(),
    $activities->stepB(),
));

// Race (first one wins)
$winner = $env->await($env->any(
    $activities->fastPath(),
    $activities->slowPath(),
));

// Quorum (enough is enough)
$quotes = $env->await($env->some(3, ...$providers));

Signals, queries, and updates#

Signal#

A signal is a one-way external message delivered to a running workflow. Signals have no return value.

A workflow declares a handler for the signals it accepts, and the engine calls it when one arrives. The handler mutates workflow state; the body carries on when that state satisfies a condition:

HTTP request → signal("approve") → handler runs → condition holds → workflow resumes
enum OrderSignal: string
{
    case Approve = 'approve';
    case Cancel  = 'cancel';
}

#[AsWorkflow('Order')]
final class OrderWorkflow
{
    /** @var list<array<string, mixed>> */
    private array $approvals = [];

    public function __construct(private readonly WorkflowEnvironment $environment) {}

    #[AsSignalMethod(OrderSignal::Approve)]
    public function onApprove(array $payload): void
    {
        $this->approvals[] = $payload;
    }

    #[AsWorkflowMethod]
    public function run(): string
    {
        $this->environment->await(fn(): bool => [] !== $this->approvals);

        return 'approved by ' . ($this->approvals[0]['by'] ?? '?');
    }
}

A workflow written as a callable registers the same handler imperatively, exactly as it would a query handler:

$env->onSignal(OrderSignal::Approve, function (array $payload) use (&$approvals): void {
    $approvals[] = $payload;
});
$env->await(function () use (&$approvals): bool { return [] !== $approvals; });

Use signals for: approval flows, pause/resume, external triggers.

Name signals with a string-backed enum, not a literal. Both ends take a BackedEnum, the handler that consumes the signal and the client that sends it, so one enum enumerates a workflow’s signal surface and a typo is a type error instead of a wait that never settles (see DUR034):

$client->signal($workflowId, OrderSignal::Approve, ['by' => 'alice']);

$client is the Temporal WorkflowClientInterface: signal() exists only on Temporal. With the Symfony bundle, on every backend, a signal is a message you dispatch on the bus. On Temporal, the bundle’s handler forwards it to the cluster, so the same line works there too:

use Gplanchat\Durable\Transport\DeliverWorkflowSignalMessage;

$bus->dispatch(new DeliverWorkflowSignalMessage($workflowId, OrderSignal::Approve, ['by' => 'alice']));

Route DeliverWorkflowSignalMessage in messenger.yaml the way Getting started routes it. The Laravel package delivers no signals yet.

A plain string is still accepted, and has to be, because a signal can arrive from curl, the Temporal CLI, or a service written in another language. The enum types the application side; it cannot type that boundary.

Migrating from waitSignal(). The method is gone. It read history directly, which is why it needed a positional slot, a per-name counter, and a rule for a wait that gave up: machinery a handler plus a condition replaces outright (see DUR035).

// Before
$approval = $env->waitSignal(OrderSignal::Approve, Duration::hours(1));

// After
$env->onSignal(OrderSignal::Approve, fn(array $p) => $this->approvals[] = $p);
$env->await(fn(): bool => [] !== $this->approvals, deadline: Duration::hours(1));
$approval = array_shift($this->approvals);

What you keep of the deliveries is now workflow state, so a workflow that waits for the same signal three times keeps three entries and consumes them at its own pace. What arrived while nothing was waiting is still there when the next wait comes.

Query#

A query is a synchronous read of workflow state. The workflow exposes a #[AsQueryMethod] that reads an internal variable; the caller gets the current value without changing workflow state. Queries are not recorded in history.

Use queries for: checking progress, reading a counter, inspecting a list of pending items.

Update#

An update is a transactional message: the workflow processes it and returns a response to the caller. The interaction is recorded in history. Updates combine signal semantics (state change) with query semantics (return value).

The handler’s return value is the response. That is the difference from a signal, which has no response:

#[AsUpdateMethod('greet')]
public function greet(array $arguments): string
{
    $this->name = (string) ($arguments['name'] ?? 'World');

    return $this->name;   // what the caller receives
}

A handler that raises makes the update fail: the caller receives the failure, and the execution carries on. On replay the handler runs again to rebuild the state it mutated, while the recorded outcome stays the one the caller already received.

Use updates for: incrementing a counter and returning the new value, conditional approvals that must return an acknowledgment.


Timers#

WorkflowEnvironment::timer() returns an Awaitable that resolves after a duration. Like activities, timers are durably recorded: after a restart the timer state is reconstructed from history and the workflow resumes at the right time without re-executing elapsed timers.

// Wait 30 minutes before continuing
$env->await($env->timer(new \DateInterval('PT30M')));

Child workflows#

A workflow can spawn child workflows to decompose complex processes into independently-tracked sub-units. Each child has its own history and can be monitored separately in the Temporal UI.

Child workflows can run asynchronously (fire-and-forget) or be awaited by the parent.


Backends#

Durable runs on four backends that share the same workflow and activity code:

┌──────────────────────────────────────────────────────────────────────┐
│                           Application code                           │
│             (workflows, activities, WorkflowEnvironment)             │
└───────────────────────────────────┬──────────────────────────────────┘
                                    │ same API
      ┌──────────────┬──────────────┼──────────────┐
      ▼              ▼              ▼              ▼
┌───────────┐  ┌───────────┐  ┌───────────┐  ┌───────────┐
│ In-Memory │  │    DBAL   │  │ Illuminate│  │  Temporal │
│   tests,  │  │  one SQL  │  │  one SQL  │  │ a cluster,│
│   local   │  │ database, │  │ database, │  │  at scale │
│           │  │  Doctrine │  │  Laravel  │  │           │
└───────────┘  └───────────┘  └───────────┘  └───────────┘

In-Memory#

  • Runs entirely in a single PHP process.
  • No external server, no persistence between requests.
  • Async activity dispatch is simulated by an in-process transport.
  • Suited to all automated tests and quick local experiments.

DBAL#

  • The journal, the resume metadata and the parent/child links become tables in one SQL database, through Doctrine DBAL.
  • No orchestration server, no sidecar, no ext-grpc.
  • Survives restarts and deploys, at a scale one database can hold.

Illuminate#

  • The same four stores and the same trade, on Illuminate\Database\Connection rather than Doctrine’s, on a connection of its own rather than the application’s (DUR054).
  • It is not a fourth value of backend and never will be: a Laravel application does not read the bundle’s YAML. What binds it is gplanchat/durable-laravel, through its own config/durable.php.

Temporal#

  • Production-grade orchestration with a real Temporal cluster.
  • Full history persistence, durable retries, Temporal UI, and the three things no journal backend has: search attributes, cron schedules, and Nexus.
  • Workers (the processes that pull work: they replay workflows and run activities) poll Temporal over gRPC, through whatever the host runs its workers with.
  • Requires the ext-grpc PHP extension.

On Magento, three states are possible, and none uses the SQL bridges. gplanchat/durable-magento declares a Composer conflict on both bridges: Magento\Framework\App\ResourceConnection is neither Doctrine DBAL nor Illuminate’s connection. Its database backend is its own adapter, on the connection that resource/durable names in app/etc/env.php. A DSN in durable/temporal/dsn selects Temporal. With neither, the journal lives in one process. Declaring both fails at boot with BackendSelectionException.

For setup details, see Backends.


How work reaches a worker#

Durable has no transport of its own. Workflow and activity work travels over what the host already has, and the mechanism differs from one host to the next.

Symfony uses Messenger. ResumeWorkflowMessage routes to the workflow task queue, ActivityMessage to the activity task queue, and signals, updates and timer fires go on the synchronous bus. With the Temporal backend the transport is a gRPC-backed polling transport: the same consumer interface, a different underlying protocol.

Laravel uses the queue the application already drains. Activities and resumes are jobs, a timer is a deferred timer-firing job on the queue’s own delay, and php artisan queue:work is the only worker.

Magento uses neither. Workers are bin/magento durable:worker --role=journal|activity commands that poll the backend directly; nothing rides Magento’s own MessageQueue. On Temporal, an activity is already a Temporal command and a resume a workflow task. On the database backend, the workers poll the table queues of the journal’s connection.

A resume is delivered at least once#

On Symfony and Laravel, whoever journals a fact a workflow waits on sends the resume before the append, and again after: an activity’s outcome, a child’s outcome reported to its parent, a delivered signal, a fired timer. A worker that dies between the two leaves a resume behind, not a fact that nothing resumes. So a resume may arrive more than once, and before the fact it announces. One that arrives early waits for the fact through the queue’s own retry. A second delivery replays the journal and changes nothing. A signal delivered twice is journalled once: it carries its delivery’s request id. Temporal delivers workflow tasks itself, and the contract doesn’t apply there. The reasoning is recorded in DUR050 and DUR052.

For configuration, see Getting started and Configuration reference.


Determinism and the replay contract#

The replay contract is the core constraint: any code inside #[AsWorkflowMethod] must produce the same sequence of awaitable operations given the same history.

Allowed in a workflow:

  • Calling activityStub() methods → returns Awaitable
  • Calling await(), all(), race(), any() → suspend/combine awaitables
  • Calling timer() → set a timer
  • Reading signal/update state set in #[AsSignalMethod] / #[AsUpdateMethod]
  • Pure computation on workflow-local variables

Not allowed in a workflow (do in activities instead):

  • new \DateTime() / time() / random_int(): non-deterministic
  • file_get_contents(), curl_exec(), database queries
  • static or global mutable state shared across workflow executions
  • sleep() (the PHP function): use $env->sleep(), which suspends durably instead of blocking the worker

Going deeper#