Nexus operations#
Nexus lets a workflow call an operation owned by another team, another namespace, another deployment — without either side knowing the other’s workflows. Durable does both roles: it calls operations, and it serves them.
Serving requires the Temporal backend. The in-memory and DBAL backends have no cross-namespace route, and they say so rather than pretending — see Backends.
That does not mean giving up a SQL journal. durable.temporal.journal: false says the cluster is
reachable while event_store stays the source of truth — which is how a shop whose dashboard reads
DBAL serves a Nexus operation without that dashboard changing what it reads. Calling is the other
way round: an operation is scheduled by a workflow, and a workflow can only schedule one if its
journal is the cluster.
Calling an operation#
#[AsNexusService('billing')]
interface BillingContract
{
#[AsNexusOperation('charge')]
public function charge(string $order, int $amount): array;
}$billing = $env->nexusStub(BillingContract::class, endpoint: 'payments');
$receipt = $env->await($billing->charge('ORD-42', 1200));The contract is written once and read from both sides of the boundary: the caller derives a typed stub from it, the handler implements it. No operation name is retyped as a string, so a typo is a type error rather than an operation waiting for a handler whose name will never match.
The endpoint is a parameter of the stub, not of the contract: it says where the service is served, which is a deployment concern and changes between environments, while the contract does not.
nexusStub() assembles; await() waits. Same rule as everywhere else — see
Creating a workflow.
The payload travels as you wrote it. There is no Durable envelope around it, so a handler written with the Go, Java or TypeScript SDK reads the fields it declares.
That is also the constraint on what a contract may declare. The payload is plain JSON, keyed by
parameter name, and it is decoded associatively on the other side. A parameter typed as an
object would arrive as an array, and the handler would raise a TypeError at the moment it is
called — not when you wrote the contract. Contracts therefore carry scalars and arrays. One PHP
detail is worth knowing: an empty associative array encodes as [] and not {}, so a field that
can be empty needs a companion field saying whether to read it at all.
Whether the handler answers immediately or hours later changes nothing here: the workflow waits on the operation, and the result arrives when it arrives.
Serving an operation#
A handler implements the contract — or the part of it that it answers immediately:
use Gplanchat\Durable\Attribute\AsNexusServiceHandler;
#[AsNexusServiceHandler(contract: BillingServed::class)]
final class Billing implements BillingServed
{
public function verify(string $order): array
{
return $this->rules->check($order);
}
}Why the contract comes in two pieces#
An operation fulfilled by a workflow has no handler body — the plumbing starts the workflow, and the server delivers its result. So the contract splits: the interface a handler implements, and the one that extends it for the caller.
#[AsNexusService('billing')]
interface BillingServed // answered immediately
{
#[AsNexusOperation('verify')]
public function verify(string $order): array;
}
#[AsNexusService('billing')]
interface BillingContract extends BillingServed // + what a workflow fulfils
{
#[AsNexusOperation('charge')]
public function charge(string $order, int $amount): array;
}
#[AsWorkflow]
#[FulfilsNexusOperation(BillingContract::class, 'charge')]
final class Charge { /* … */ }Without the split, PHP would demand a body for charge() on the handler — an empty method whose only
job is to say there is nothing to write. The workflow claims the operation instead, where its code
actually lives, and the caller’s contract still declares everything so the stub can call it all.
Answering now, or answering later#
There are two forms, and choosing between them is the one decision that matters.
// Now — the handler returns the contract's own type.
public function verify(string $order): array { … }
// Later — a workflow claims the operation, and produces the result.
#[FulfilsNexusOperation(BillingContract::class, 'charge')]
final class Charge { … }A handler has roughly nine seconds. That is not the operation’s budget, it is the budget for
answering this task: the caller’s scheduleToClose may be five minutes, but the task itself
carries a request-timeout of about nine seconds. A handler still working when it expires has its
task redelivered — and starts over. Measured redeliveries: ~9.9 s, ~20.7 s, ~33.6 s.
So an implemented method is for a lookup, a validation, a computation you already know is fast.
Anything that talks to a payment provider, waits on a human, or retries for a day belongs in a
workflow — and that is what #[FulfilsNexusOperation] declares.
When you name a workflow, Durable starts it with the caller’s callback attached, and the server delivers that workflow’s result to the caller when it finishes. Your handler is not called again.
Cancellation#
If the caller cancels, Durable cancels the workflow fulfilling the operation. You do not write a cancellation hook: your workflow already observes cancellation, and compensates, exactly as described in Cancellation.
A cancellation only reaches a handler for an operation that has started — an operation still waiting for its first answer has nothing to cancel on your side.
Failing#
Raise, and the operation fails:
throw new \RuntimeException('the payment provider is unreachable');An ordinary exception is reported as INTERNAL, which is retryable — the task comes back, up to
the operation’s budget. That is right for an outage and wrong for a bad request, which will never
improve. For a terminal refusal, say which kind it is:
| terminal — do not retry | retryable — try again |
|---|---|
BAD_REQUEST, UNAUTHENTICATED, UNAUTHORIZED |
RESOURCE_EXHAUSTED, INTERNAL |
NOT_FOUND, NOT_IMPLEMENTED, CONFLICT |
UNAVAILABLE, UPSTREAM_TIMEOUT, REQUEST_TIMEOUT |
The line is whose fault is it. A malformed request or a missing right will not be fixed by retrying; an overload or an upstream timeout might. The table is nexus-rpc’s, shared by every language SDK — not a Durable invention.
An operation nobody serves is answered NOT_IMPLEMENTED, terminal, and the worker keeps serving
its other operations.
Running the worker#
Serving needs a worker on the Nexus task queue. It is a Messenger transport, like the activity worker:
# config/packages/messenger.yaml
framework:
messenger:
transports:
durable_temporal_nexus:
dsn: '%env(DURABLE_DSN)%'
options:
purpose: nexus_workerphp bin/console messenger:consume durable_temporal_nexus --time-limit=3600The queue comes from the DSN. nexus_task_queue sets it; it defaults to the workflow task
queue, because a Nexus endpoint targets a queue and the server only delivers where someone polls.
A queue nobody serves is an endpoint that never answers, without an error anywhere.
Registering the endpoint#
An endpoint is a cluster-wide object, created once by an operator, not by the application:
temporal operator nexus endpoint create \
--name payments \
--target-namespace production \
--target-task-queue durable-workflowsThe --target-task-queue must be the queue your Nexus worker polls.
If you declare a handler on the wrong backend#
The container refuses to build, and names what is missing:
durable.nexus_handler: a Nexus handler is declared, but this backend cannot route
Nexus operations. Nexus needs the Temporal backend — set durable.temporal.dsn.
Declared by: app.encaisser.This is deliberate, and it is not how the caller side behaves. A call on a backend with no route fails at the call — you find out immediately. A handler with no route is not a call that fails, it is a service that never receives anything, silently. There is no request left to fail, so the refusal happens when the application starts.
Four applications, for real#
The repository ships a demonstration where four Durable applications call each other, across three frameworks. What it shows is easier to read than to describe.
sylius/ — the shop |
symfony/ — the back office |
magento/ — the Magento bench |
laravel/ — the logistics |
|
|---|---|---|---|---|
| namespace | demo-boutique |
demo-metier |
demo-magento |
demo-laravel |
| serves | stock (reserver) |
facturation (verifier, encaisser) |
nothing | livraison (planifier, expedier) |
| calls | facturation |
stock |
all three services | stock, from the workflow that serves |
| what declares the handler | a tag under when@demo |
#[AsNexusServiceHandler] |
— | six lines of config/durable.php |
All four read the same contract package. Nothing else travels between them.
The shop’s order workflow calls both forms on the same stub:
$verdict = $this->environment->await($this->facturation->verifier($commande, $montant, $devise));
if (true !== ($verdict['acceptee'] ?? false)) {
return ['verifiee' => $verdict, 'encaissement' => null];
}
return [
'verifiee' => $verdict,
'encaissement' => $this->environment->await($this->facturation->encaisser($commande, $montant, $devise)),
];verifier is answered by a method the back office wrote. encaisser has no handler body at all: a
workflow claims it, sleeps twelve seconds, calls a payment activity, and its result becomes the
operation’s. Nothing in the code above distinguishes the two. The caller’s history does:
5 NexusOperationScheduled verifier
6 NexusOperationCompleted verifier ← same second
10 NexusOperationScheduled encaisser
11 NexusOperationStarted encaisser ← a workflow took it
15 NexusOperationCompleted encaisser ← fourteen seconds later
19 WorkflowExecutionCompletedDuring one run, the worker that was to advance the fulfilling workflow stayed off for four
minutes. The operation sat at NexusOperationStarted, the caller consumed nothing, and everything
finished normally when the worker came back. That is what “the wait holds nothing open” means, and
it is not a claim you can make from a diagram.
Calling asks nothing of your host#
The third application is there to separate what Nexus asks of the framework from what it asks of you. The first two are both Symfony: they share its container, the compile pass that registers handlers, and the Messenger transport that runs the workers. You could reasonably read all of that as a feature of the bundle.
The Magento bench has none of it. It wires services in di.xml, runs its worker with
bin/magento durable:worker --role=journal, and reads its DSN from app/etc/env.php. It calls all
three services — the immediate ones and the two a workflow fulfils — and not one line was added to
the core, to the Temporal bridge or to gplanchat/durable-magento to make that work.
The reason is that the two sides are not symmetrical:
- Calling needs a workflow whose journal is the cluster, and nothing else.
WorkflowEnvironment::nexusStub()reads the contract by reflection; no container is involved. - Serving needs the host to register handlers and to poll a Nexus task queue. That is host work, written once per host — a compile pass in Symfony, a config file in Laravel, and, for now, nothing in Magento.
That asymmetry has a visible consequence in the cluster: four namespaces, three endpoints. An endpoint says where a service is served, so an application that only calls has none.
// The Magento bench, calling three services from one workflow. This is the whole of the host
// integration: three stubs and five awaited operations.
$verdict = $this->environment->await($this->facturation->verifier($commande, $montant, $devise));
$livraison = $this->environment->await($this->livraison->planifier($commande, $lignes));
$reservation = $this->environment->await($this->stock->reserver($commande, $lignes));
$recu = $this->environment->await($this->facturation->encaisser($commande, $montant, $devise));
$suivi = $this->environment->await($this->livraison->expedier($commande, $livraison['creneau']));⚠ The order of those five calls is not cosmetic. Two inversions were written first, and both were measured: an order in USD reserved the stock and then had its invoice refused, and an order of six parcels was charged before the logistics refused to carry it. None of the three contracts has an operation that gives back what it took. Ask everything that can say no first, commit afterwards — when an operation has no compensating counterpart, the order of the calls is the compensation.
Serving is host work, and it is not Symfony work#
The other half of the asymmetry gets its own demonstration, because until the Laravel mockup existed every served operation had been registered by a Symfony compile pass and polled by a Symfony transport. Here is the whole of the host wiring on a framework that has neither:
// config/durable.php
'backend' => env('DURABLE_BACKEND', 'temporal'), // serving Nexus needs the cluster: it routes
'temporal' => ['dsn' => env('DURABLE_DSN')],
'workflows' => [App\Durable\Workflow\ExpedierWorkflow::class],
'nexus' => ['handlers' => [
App\Durable\Nexus\LivraisonHandler::class => LivraisonContract::class,
]],DeclaredNexusOperations reads that file the way NexusHandlerPass reads Symfony’s tags, through
the same NexusContractResolver and the same NexusHandlerInvoker; php artisan durable:nexus-worker
polls the queue. The handler class knows none of it — it implements LivraisonServed and says
nothing about Nexus.
⚠ The check that keeps this honest lives in the core, not in either host. A fulfilling
workflow whose required parameter matches nothing in the contract’s signature is refused at
registration, and the message names both signatures — because the payload is keyed by parameter name
at both ends, and without that refusal the parameter would simply receive null. Symfony calls the
check from its compile pass, Laravel from durable.nexus.handlers; it was written for the first host
and moved the day it had a second.
A workflow that serves can call#
ExpedierWorkflow fulfils livraison/expedier. Before releasing the goods it asks the shop for its
verdict again, through stock/reserver, on an endpoint that is not its own — so one execution
carries an operation it serves and an operation it calls:
5 TimerStarted ← six seconds of picking
6 TimerFired
10 NexusOperationScheduled ← stock/reserver, at the shop
11 NexusOperationCompleted
15 WorkflowExecutionCompletedIts workflow id is the operation token of the operation it fulfils — a workflow started by a Nexus task is not named by the application that runs it.
The call is safe because reserver is idempotent per order id: the shop re-reads the decision it
made at order time instead of taking a new one, which is why the lines passed are empty.
Prerequisites, the processes to start and the commands to run are in
demo/README.md. Two things to
know before you start: a server that answers Nexus APIs are disabled will not do —
temporal server start-dev will — and the four mockups do not run on the same PHP binary.
See also#
- Backends — which backend can route Nexus, and why the others refuse.
- Cancellation — what your fulfilling workflow does when the caller cancels.
- DUR045 — the decision record, and the measurements behind it.