Backends#
Durable has five execution backends. A backend is where the journal lives and what schedules the work; the journal is the append-only record of everything an execution decided and received (see the glossary). One backend is In-Memory; the other four are the bridges you choose between.
| Backend | Use case |
|---|---|
| In-Memory | Unit tests, functional tests, local exploration; no server needed. |
| DBAL | Production without an orchestration cluster: one SQL database, no ext-grpc. |
| Illuminate | The same, on Laravel’s connection rather than Doctrine’s. |
| Temporal | Production and staging at scale, realistic integration tests; ext-grpc and a Temporal cluster required. |
On Magento, the two SQL bridges are not available.
gplanchat/durable-magentodeclares a Composerconflicton both SQL bridges:Magento\Framework\App\ResourceConnectionis neither Doctrine DBAL nor Illuminate’s connection, so neither bridge has anything to bind to. The module has its own database backend instead.RuntimeFactoryassembles exactly one backend, chosen byapp/etc/env.php: the database whenresource/durableis declared, else Temporal whendurable/temporal/dsnis declared, else memory. Declaring bothresource/durableand a DSN fails withBackendSelectionException.
All five run the same fiber driver and the same workflow and activity code. You select three
of them with durable.backend (and DURABLE_DSN for Temporal). On Magento, app/etc/env.php
selects the backend. Illuminate is not one of the durable.backend
values and never will be; the Illuminate backend describes what binds it
instead.
In-Memory backend#
The In-Memory backend runs entirely inside a single PHP process. It needs no external server and no gRPC, and nothing persists between requests.
How it works#
- Workflow and activity messages are dispatched through Symfony Messenger in-memory transports.
- The event history lives in an
InMemoryEventStore. - The Messenger drain processes messages synchronously when you call
drainMessengerUntilSettled()or the equivalent.
Configuration#
# config/packages/durable.yaml (or when@test:)
durable:
backend: in_memory
activity_transport:
type: messenger
transport_name: durable_activities
# config/packages/messenger.yaml (or when@test:)
framework:
messenger:
transports:
durable_workflows: 'in-memory://'
durable_activities: 'in-memory://'
routing:
Gplanchat\Durable\Transport\ResumeWorkflowMessage: durable_workflows
Gplanchat\Durable\Transport\ActivityMessage: durable_activitiesWhen to use it#
- All unit and functional tests (see Testing workflows).
- Local development when you do not need Temporal’s durable history or UI.
- CI jobs that run without Docker.
Temporal backend#
The Temporal backend delegates workflow orchestration to a real Temporal cluster. The PHP process communicates over gRPC via ext-grpc.
How it works#
- When
DURABLE_DSNis set,DurableExtensionregisters the Temporal-specific services (WorkflowClient,TemporalHistoryCursor, workers). - Starting a workflow calls
StartWorkflowExecutiongRPC on Temporal. - The
durable_workflowsworker (a process that pulls work; see the glossary) polls workflow tasks. - The
durable_activitiesworker polls activity tasks. - Each workflow task replays history with the fiber-based
WorkflowTaskRunnerand sends commands back to Temporal. Replay runs the workflow code again from its first line and answers each recorded step from the journal.
The bundle registers these workers itself, from durable.temporal.dsn: messenger:consume finds
them by name, and messenger.yaml declares no Temporal transport. A third one, durable_nexus,
exists when the application serves a Nexus operation.
Waiting for the result with pollForCompletion()#
WorkflowClient::pollForCompletion() reads the run’s close event until it arrives, then returns the
result or throws. Two things differ from a run on the journal backends:
- A workflow that lets an activity failure escape throws
DurableWorkflowAlgorithmFailureException, as on the journal backends. Its previous exception is anActivityFailureCauseExceptionthat carries the original class and message, not the original exception object. Any other workflow failure throws a plain\RuntimeExceptionwhose message starts withWorkflow "<execution id>" failed:, not the workflow’s own exception. - A workflow that waits on a signal nobody sends fails at once in memory, because nothing else can
make it progress. On Temporal the run stays open, and
pollForCompletion()waits until its polls run out, then throwsWorkflowStuckException.
Prerequisites#
-
ext-grpcPHP extension compiled against thegrpc/grpcpackage version required by the bridge. -
A running Temporal cluster, Server 1.20 or newer. 1.20 is the oldest version Durable supports, because it is the first where SQL visibility (PostgreSQL, MySQL, SQLite) accepts custom search attributes. On PostgreSQL that takes the
postgres12persistence plugin (DB=postgres12with theauto-setupimage). The olderpostgresqlplugin only offers standard visibility, which cannot filter on custom search attributes, so the run list’s filters fail there. CI runs the run-list and visibility-query suites againsttemporalio/auto-setup:1.20on PostgreSQL, as well as against a current server. -
The run list’s filter by execution-id prefix needs Server 1.23 or newer: older servers, 1.22 included, cannot run the
STARTS_WITHit takes. On those servers the catalog throws aRunFilterUnavailableExceptionnaming 1.23 for a prefix, and the dashboards offer only the name filter. Filtering by exact workflow name works from 1.20. -
Updates (
#[AsUpdateMethod],onUpdate()) need Server 1.21 or newer. On 1.20, when the workflow task that answers an update also completes the workflow, the server writes no update event to the history, and a later replay does not see the update. From 1.21 through 1.24, updates are switched off by default: set the dynamic config valuefrontend.enableUpdateWorkflowExecutiontotrue. Without it,WorkflowClient::update()fails withUpdateWorkflowExecution operation is disabled on this namespace. In the server’s dynamic config file (frontend.enableUpdateWorkflowExecutionAsyncAcceptedis not needed: Durable waits for the update’s COMPLETED stage):frontend.enableUpdateWorkflowExecution: - value: true
Install ext-grpc#
pecl install grpc
# Add to php.ini: extension=grpcVerify:
php -m | grep grpcIn a container image, copy the extension instead of compiling it. pecl install grpc takes
about seven minutes, and your image build spends them on every branch. Prebuilt extensions are
published for PHP 8.2 to 8.5, in
thread-safe and non-thread-safe forms; see gRPC in your container image
for the COPY --from recipes, including php-fpm, mod_php and FrankenPHP.
Filter runs by workflow name and execution id#
With search_attributes turned on, Durable writes two search attributes on every run it starts,
so the run list can filter by workflow name and by execution id. The option is off by default. A
start that sets an attribute not registered in the namespace fails on the server, so register both
once per namespace, before turning the option on:
temporal operator search-attribute create --namespace default \
--name DurableWorkflowName --type Keyword \
--name DurableExecutionId --type Keyword-
You can run the command again: it succeeds as long as the type is the same.
-
Wait a few seconds before the first start. The attributes show up in
search-attribute listright away, but for a couple of seconds a start that sets them still fails withNamespace default has no mapping defined for search attribute DurableExecutionId. A script can wait until this query stops failing:until temporal workflow list --namespace default --limit 1 \ --query "DurableExecutionId = 'probe'" >/dev/null 2>&1; do sleep 1; done -
On Temporal Cloud, add the two attributes to the namespace in the Cloud UI or with
tcld namespace search-attributes add. -
With SQL visibility (PostgreSQL, MySQL, SQLite), custom search attributes need Server 1.20 or later. A namespace holds at most 10 Keyword attributes there, and Durable uses two of them.
Then turn the option on:
# config/packages/durable.yaml
durable:
temporal:
search_attributes: trueOn Laravel, set 'search_attributes' => true under temporal in config/durable.php. On Magento,
set durable/temporal/search_attributes to true in app/etc/env.php.
How the values are written. A backslash becomes a dot, so a class name reads
App.Workflow.OrderWorkflow: Temporal’s query parser matches no value that contains a backslash.
A . or % already in the value becomes %2E or %25, so two workflow names never end up with
the same value. Temporal documents a limit of 255 characters per value. A longer value keeps at most its
first 189 bytes and ends with a hash of the whole, so an exact filter still finds it, but a prefix
filter only matches within those first bytes.
Runs started before the option was on don’t carry the attributes. They still show up in the unfiltered run list, but filtering by workflow name or by execution id doesn’t find them.
Docker Compose setup (local / CI)#
The repository includes a ready-to-use compose.yaml under symfony/ that starts:
- PostgreSQL 16 (shared between the application and Temporal)
temporalio/auto-setup:1.25.2(auto-configures schema on startup)- Temporal UI (on port 8088)
cd symfony
docker compose up -dThe temporal service registers Durable’s search attributes
itself, and only reports healthy once a start can use them; the bench turns the option on. Wait for the stack to be healthy, then start the Symfony workers:
php bin/console messenger:consume durable_workflows --time-limit=3600
php bin/console messenger:consume durable_activities --time-limit=3600The symfony serve binary reads .symfony.local.yaml and starts workers automatically if configured there.
Configuration#
# .env.local (dev/prod)
DURABLE_DSN=temporal://127.0.0.1:7233?namespace=default&journal_task_queue=durable-journal&activity_task_queue=durable-activities&tls=0# config/packages/durable.yaml
durable:
backend: temporal
temporal:
dsn: '%env(DURABLE_DSN)%'Nothing goes in messenger.yaml for Temporal. If it still declares durable_workflows or
durable_activities under this backend, the container fails to compile, and the error names the
transport to remove, because those names belong to the bundle’s workers. The exception is
durable.backend: dbal with a DSN: workflows then run locally, and those two transports stay the
application’s.
Temporal UI#
With the default Docker setup, the Temporal Web UI is available at http://localhost:8088. It shows running and completed workflows, their history, and failed activities.
DSN parameters#
| Parameter | Required | Example | Description |
|---|---|---|---|
namespace |
yes | default |
Temporal namespace. Use distinct namespaces per application/environment. |
journal_task_queue |
yes | durable-journal |
Task queue for the workflow task worker. |
activity_task_queue |
yes | durable-activities |
Task queue for the activity worker. |
tls |
no (default 0) |
tls=1 |
Enable TLS for gRPC. Required for Temporal Cloud. |
Temporal Cloud#
For Temporal Cloud, set TLS and the Cloud endpoint:
DURABLE_DSN=temporal://ACCOUNT.REGION.tmprl.cloud:7233?namespace=NAMESPACE.ACCOUNT&journal_task_queue=durable-journal&activity_task_queue=durable-activities&tls=1With an API key, add api_key= (URL-encoded); with mTLS, cert= and key= (paths to PEM files);
with a private CA, ca=. See the DSN parameters for the whole list.
DBAL backend#
The DBAL backend persists the journal, the resume metadata, the parent/child links and the run
catalog (the list of executions a dashboard reads) in a single SQL database through Doctrine
DBAL. There is no orchestration server, no sidecar and no ext-grpc. See DUR030.
How it works#
- The four process-local stores become SQL tables: the event journal, the workflow metadata, the
parent links between child workflows, and the run catalog. A fifth table,
durable_execution_heads, holds a counter per execution that stops a superseded resume from writing to the journal (DUR053). Everything else (replay, command buffer, lifecycle) is the code the In-Memory backend already runs. - Resumes and activities ride Symfony Messenger, so use a durable transport (Doctrine, Redis,
AMQP). An
in-memory://transport throws away what the SQL journal just persisted. - Timers ride Messenger
DelayStampthroughFireWorkflowTimersHandler. - Tables are created on first write: no migration to run, no
doctrine/migrationsdependency.bin/console durable:setupcreates them up front; run it whendbal.auto_setupisfalse, or when the first write happens inside a transaction, where auto-creation fails on every platform (on MySQL, theCREATE TABLEwould commit that transaction).
Configuration#
Give the journal a connection of its own. Sharing the application’s is strongly discouraged (DUR054): Durable’s transactions then nest inside business ones. A worker that starts with the journal on the application’s default connection logs a warning saying so. Better still, point that connection at a database (or a schema) and a database user of Durable’s own, so that business code cannot reach the journal’s tables at all.
# config/packages/doctrine.yaml: the journal on a connection of its own
doctrine:
dbal:
default_connection: default
connections:
default:
url: '%env(resolve:DATABASE_URL)%'
durable:
url: '%env(resolve:DURABLE_DATABASE_URL)%'# config/packages/durable.yaml
durable:
dbal:
connection: doctrine.dbal.durable_connection
lock_factory: lock.factory
backend: dbal
activity_transport:
type: messenger
transport_name: durable_activities
framework:
lock:
default: '%env(LOCK_DSN)%' # a DBAL URL (postgresql://…, mysql://…), redis://…; not Messenger's doctrine://; shared across workersDoctrineBundle names each connection’s service doctrine.dbal.<name>_connection. On a database
other than the ORM’s, doctrine:migrations:diff does not see Durable’s tables: they come from the
first write, or from bin/console durable:setup.
Adding a temporal.dsn keeps the journal in SQL and uses the cluster only to serve Nexus operations.
With backend: temporal, the cluster holds the journal instead. The journal lives in exactly one place in
both cases.
The Doctrine transport on PostgreSQL#
On PostgreSQL, set use_notify: false on Durable’s Doctrine transports:
framework:
messenger:
transports:
durable_workflows:
dsn: 'doctrine://default?queue_name=durable_workflows'
options: { use_notify: false }
durable_activities:
dsn: 'doctrine://default?queue_name=durable_activities'
options: { use_notify: false }Once a queue is empty, Messenger’s PostgreSQL transport reads it again only on a notification or
after 60 seconds (check_delayed_interval). A worker that consumes both queues over one connection
can miss that notification, and the resume an activity sends then waits up to 60 seconds, or until
the next durable:worker starts, whatever the --sleep value. With use_notify: false, the transport polls
each queue on every loop, as it does on MySQL.
One resume at a time per execution#
Temporal serialises workflow tasks for one execution server-side. There is no server here, so two consumers can dequeue two resumes of the same execution and replay the same fiber in parallel, each appending its own commands. The result is duplicated activities and a forked journal.
Durable prevents this with a per-execution lock (SingleResumeLockMiddleware), registered
automatically when the DBAL event store is active. The lock is only as safe as your lock store.
With several workers, an in-memory or per-process lock.factory lets through exactly the failure
the lock exists to prevent. Configure a shared one.
When to use it#
- Production without operating a cluster. A Symfony app that already has a database and a Messenger transport.
- Long-running workflows that must survive deploys and restarts, at a scale one database can hold.
It does not offer search-attribute queries, cron schedules, or the throughput and visibility of a Temporal cluster. The capability matrix below lists the differences.
Illuminate backend#
The same four stores exist on Illuminate\Database\Connection, as
gplanchat/durable-bridge-illuminate
with the same journal and the same trade against Temporal.
Its trade against Temporal is the DBAL backend’s, word for word. Only the connection differs:
Illuminate\Database\Connection instead of Doctrine’s. Give it a connection of its own, separate
from the application’s (DUR054). See DUR047.
Binding it through gplanchat/durable-laravel#
Illuminate is not a fourth value of backend, and never will be, because a Laravel
application does not read this page’s YAML. The bridge is the storage half. gplanchat/durable-laravel
binds it, through its own published config/durable.php.
That package carries the queue side too: activities and resumes as jobs, a timer as a deferred timer-firing job on the queue’s own delay, and the per-execution exclusion the DBAL section describes. Its own Packages entry has the configuration, the three settings the package does not accept, and the two behaviours that look like bugs and are not.
Choosing a backend per environment#
| Environment | Backend |
|---|---|
| Unit tests | In-Memory (DurableTestCase) |
| Integration tests | In-Memory (DurableBundleTestTrait + KernelTestCase) |
| CI with Temporal | Temporal (temporal-integration group) |
| Local dev | Any: In-Memory for speed, a journal backend for realism |
| Production, no cluster | DBAL on Symfony, Illuminate on Laravel, the Magento Database backend (resource/durable) on Magento |
| Production, at scale | Temporal |
Capability matrix#
All five backends run the same fiber driver and the same activity execution path. What differs is what the surrounding platform can offer. The two SQL columns differ only in the connection they sit on, so their answers match on every row except the transport and what the host delivers (signals, updates and Nexus serving).
The Magento Database column is the backend of gplanchat/durable-magento that
resource/durable in app/etc/env.php selects (epic #740).
It runs the core’s journal, activity and workflow handlers on tables of the connection that
resource/durable names, with a table queue (a message queue stored in a database table; see the
glossary) that bin/magento durable:worker drains.
Integration tests of the Magento Database backend run only under
phpunit.magento.xml, and no CI job runs that configuration (#738).
In the table, ✅ means the capability exists and ❌ means it is absent. “not yet” marks the one capability that is absent but planned: sending a signal or an update from the application on the Magento Database backend. “n/a” means the row does not apply: single-process serialisation on the In-Memory backend, and Nexus serving on the Magento Database backend, which has no Nexus role.
| Capability | In-Memory | DBAL | Illuminate | Temporal | Magento Database |
|---|---|---|---|---|---|
| Activities, retries, timeouts | ✅ | ✅ | ✅ | ✅ | ✅ |
| Timers, side effects | ✅ | ✅ (Messenger delays) | ✅ (queue delays) | ✅ | ✅ (table queue delays) |
| Signal, update and query handlers in a workflow | ✅ | ✅ | ✅ | ✅ | ✅ |
| Sending a signal or an update from the application | ✅ (Symfony message) | ✅ (Symfony message) | ❌ (Laravel delivers none) | ✅ (client or Symfony message) | not yet |
| The update’s result returned to the caller | ❌ | ❌ | ❌ | ✅ (WorkflowClient::update()) |
❌ |
| Reading a query from the application | ❌ | ❌ | ❌ | ✅ (WorkflowClient::query()) |
❌ |
| Child workflows | ✅ | ✅ | ✅ | ✅ | ✅ |
ParentClosePolicy cascade |
✅ | ✅ | ✅ | ✅ (server-driven) | ✅ |
| Continue-as-new | ✅ | ✅ | ✅ | ✅ | ✅ |
Cancellation with compensation (a parent’s RequestCancel) |
✅ | ✅ | ✅ | ✅ | ✅ |
| Cancellation requested from outside | ❌ | ❌ | ❌ | ✅ | ❌ |
| Survives process restart | ❌ | ✅ | ✅ | ✅ | ✅ |
| Task serialisation per execution | n/a (single process) | application lock | application lock | ✅ server-side | application lock (GET_LOCK) |
| Search attributes | journaled only | journaled only | journaled only | ✅ indexed and queryable | journaled only |
| Cron schedules | ❌ no scheduler | ❌ no scheduler | ❌ no scheduler | ✅ | ❌ no scheduler |
| History retention / visibility API | ❌ | your SQL table | your SQL table | ✅ | run catalog table, shown in the admin grid |
| Calling a Nexus operation | ❌ | ❌ | ❌ | ✅ | ❌ |
| Serving a Nexus operation | ✅ with temporal.dsn (Symfony) |
✅ with temporal.dsn (Symfony) |
❌ | ✅ (Symfony, Laravel, Magento) | n/a |
gplanchat/durable-magento ships no signal or update delivery. On the journal backends, a signal
or an update sent from the application is journaled, and the workflow’s next pass handles it; the
sender gets no answer. A query has no application-side entry point there at all.
No backend but Temporal has a scheduler or a cross-namespace boundary, so cron and Nexus have no
equivalent on the other four. Nexus fails explicitly.
A child workflow’s namespace, taskQueue and cronSchedule fail explicitly too: a journal
backend fails with UnsupportedByBackendException naming the option. Search attributes are the
exception: a child workflow’s are written into the journal and nothing reads them outside Temporal,
and the start options of a root workflow exist only on the Temporal client. A Nexus call fails at
the call. A Nexus handler with no route never sees a failing call: it is a service that never
receives anything. On Symfony, the container build fails when
durable.temporal.dsn is not set. On Magento, bin/magento durable:worker --role=nexus fails with
A Nexus worker needs a cluster when app/etc/env.php has no DSN. With resource/durable, the same
command fails with The database backend has no Nexus role: the database backend serves no Nexus
operation, and --role=journal, --role=activity or no --role start a worker.
On Laravel, the provider fails at boot with NexusUnsupportedByBackendException when a handler is listed in
durable.nexus.handlers and the backend is not temporal. The message names the backend.
Starting a run from a Magento observer#
RuntimeFactory::resumeDispatcher()->dispatchNewWorkflowRun() has the same signature on every Magento backend. On the memory and Temporal backends, a workflow
that fails does not throw from the call, and an undeclared workflow does
(UndeclaredWorkflowException). One difference remains, and it is named here as an exception to the
rule that the application behaves the same on every backend. On Temporal, the call starts the run
and returns. On the Magento memory backend, the run executes in the calling process, so the request
waits for it, for up to budgetSeconds (10 by default), and a workflow that waits on a signal or a
long timer holds the request for the whole budget. Nothing else can advance an in-memory run, so
the wait cannot be removed. Set durable/temporal/dsn where a request must not wait.
On the Magento database backend, the call records the run’s metadata, queues a resume and
returns. A bin/magento durable:worker carries the run. The call does not check the workflow
registry: an undeclared workflow does not throw UndeclaredWorkflowException from the call, which
only MagentoRuntime and the in-process dispatcher raise. The failure appears when a worker takes
the resume, and the request has already returned.
A second difference concerns a failure that the call swallows. The in-memory journal ends with the request, so the log line is the only trace of it, and only when a logger is configured. On Temporal, the failure also stays in the cluster history.
Retry limits differ on one setting#
An activity with no attempt bound retries indefinitely on every backend, which is the Temporal default.
max_activity_retries is the exception. On the in-memory and journal backends (DBAL, Illuminate) and on the Magento Database backend,
the worker narrows the activity’s own RetryLimit to that ceiling, and the stricter of the two
applies; at 0 it caps nothing. On Magento, the ceiling is the maxActivityRetries argument of
RuntimeFactory. On Temporal, the cluster retries from the activity’s own
RetryLimit and the setting is not read: an activity that the ceiling stops on the other backends
keeps retrying on Temporal.
See Failures and retries and Options.
Writing your own backend#
Two ports define a backend: WorkflowCommandBufferInterface for what a workflow asks for, and
WorkflowHistorySourceInterface for what already happened.
Both carry value objects, not primitives. An implementation receives the options as the caller built them (retry limits, timeouts, task queues, cron schedules) and owns the translation to its own representation, including serialisation and any reading of a clock.
startTimer() receives a delay, not a deadline. You turn it into an instant, with your own
clock. This lets a test harness advance a virtual clock, and lets the Temporal driver pass the
duration the server expects.
The contributor decision record is DUR031.
See also#
- Configuration reference lists every
durable.yamlkey. - Getting started covers Messenger routing and worker commands.
- Testing workflows shows the In-Memory backend used in tests.