Client setup
The client owns the runtime origin, namespace, authentication boundary, and transport. Workflow values use the official Apache Avro payload codec.
Install and construct
Install the supported stable SDK release shown throughout this portal:
composer require durable-workflow/sdk:^3.18
Pass a Server origin or the complete Cloud runtime base URI. Do not add a terminal /api.
use DurableWorkflow\Client;
$controlClient = new Client(
getenv('DURABLE_WORKFLOW_RUNTIME_URL') ?: 'http://localhost:8080',
namespace: getenv('DURABLE_WORKFLOW_NAMESPACE') ?: 'default',
controlToken: getenv('DURABLE_WORKFLOW_CLIENT_TOKEN') ?: null,
);
$workerClient = new Client(
getenv('DURABLE_WORKFLOW_RUNTIME_URL') ?: 'http://localhost:8080',
namespace: getenv('DURABLE_WORKFLOW_NAMESPACE') ?: 'default',
workerToken: getenv('DURABLE_WORKFLOW_WORKER_TOKEN') ?: null,
);
Use the shorter token: constructor argument when one credential is intentionally allowed to perform both roles. Production Cloud provisioning normally gives client and worker principals separate credentials.
Select a namespace without rebuilding the client
withNamespace() returns a new client with the same authentication and transport. Workflow payload encoding remains Apache Avro:
$billing = $controlClient->withNamespace('billing');
$orders = $controlClient->withNamespace('orders');
The original client is unchanged. This makes tenant or application boundaries visible in dependency injection instead of mutating shared state.
Start once, then retain the handle
$handle = $controlClient->startWorkflow(
workflowType: 'orders.process',
workflowId: 'order-1001',
taskQueue: 'orders',
input: [['order_id' => '1001']],
);
$result = $handle->result(timeoutSeconds: 30);
The workflow ID is the stable instance identity. The run ID identifies one execution in that instance's chain. Ordinary handle methods follow the current run after continue-as-new; methods ending in SelectedRun keep the original run guard and fail instead of silently crossing to a successor.
Read complete lists and histories
listWorkflows() and workflowHistory() each return one bounded page. The
following iterator methods read every page lazily:
foreach ($controlClient->iterateWorkflows(status: 'running', pageSize: 100) as $execution) {
echo $execution->workflowId, "\n";
}
foreach ($controlClient->iterateWorkflowHistory($workflowId, $runId, pageSize: 100) as $event) {
echo $event['sequence'], ': ', $event['event_type'], "\n";
}
Iterators retain one page and request the next only when iteration reaches it.
Stop with break to avoid further requests. The history iterator keeps the
exact workflow/run pair; it does not follow a new run or wait for new events
after the last page. Workflow listing follows the Server's current ordering
and consistency, so concurrent changes are not a snapshot.
Handle errors around the entire loop. A later HTTP error propagates and closes
the generator; malformed metadata or a token cycle raises
DurableWorkflow\Exception\PaginationException with a typed reason.
Earlier items may have been yielded before an error. Iteration preserves raw
history event values and uses the same namespace, authentication and per-request
deadline as the one-page methods.
Inject a PSR transport when you need one
Guzzle is included as the default transport. To reuse your application's PSR-18 client and PSR-17 factories, construct Psr18Transport and pass it as transport:. Authentication still belongs in an Authentication implementation; do not bury rotating role credentials in a generic HTTP middleware stack.
Bound ordinary HTTP requests
The SDK-owned transport applies a 60-second total deadline to each ordinary control HTTP request, including connection, TLS, response headers and the complete response body. Set a positive number of seconds, with millisecond precision, when constructing the client:
$client = new Client(
'https://runtime.example',
controlToken: $token,
requestTimeoutSeconds: 5.0,
);
A timeout raises ServerException with status zero and reason
request_timeout. A signal or update retains its existing exception subtype.
The SDK makes one attempt: a timeout does not prove that a mutation was rejected.
Inspect the original workflow ID or command outcome before deciding whether to
send another mutation. Do not automatically retry a start, signal or update
because the response timed out.
This is a per-request limit. Discovery, an external-payload transfer and a later
mutation can be separate HTTP requests. It does not change worker requests,
payload-transfer deadlines, workflow execution timeouts or
WorkflowHandle::result()'s caller wait. withNamespace() preserves the
configured transport and deadline.
Bound worker requests
Worker requests have their own deadline, so the control deadline never shortens
them. The SDK-owned transport applies a 60-second total deadline to each
worker request that is not a long poll: registration, worker and activity
heartbeats, workflow and activity task completion and failure, and
deregistration. Set it with workerRequestTimeoutSeconds:
$client = new Client(
'https://runtime.example',
workerToken: $token,
workerRequestTimeoutSeconds: 15.0,
);
A timeout raises ServerException with status zero, reason
request_timeout and the message The worker HTTP request timed out., instead
of leaving the worker waiting on a Server that accepted the connection but never
answered. Task polls keep their longer budget: the requested wait plus a margin.
Workers with cooperative cancellation keep their own per-operation budgets. A
task completion that times out has an uncertain outcome. The worker's existing
fenced retry resends the same completion under the original lease; it does not
run the handler again. The activity-lease helper process and withNamespace()
keep the configured value.
The owned transport uses Guzzle with cURL, or a Guzzle adapter using the mature HTTP_Request2 socket client without cURL. PHP 8.1 and JSON remain the installation requirements. HTTP-only consumers do not need OpenSSL. HTTPS needs the runtime's existing TLS support; certificates and hostnames are verified. Both paths disable redirects and automatic retries, and obtain authentication headers for each request.
The native adapter sends HTTP/1.1 with Connection: close and reads the complete
response before returning. It shares the deadline across a CONNECT tunnel and its
TLS handshake, and rejects incomplete HTTP transfers. It preserves Guzzle's HTTP_PROXY, HTTPS_PROXY and
NO_PROXY selection, including HTTP forwarding and HTTPS through an HTTP CONNECT
proxy. HTTPS-scheme proxies remain unsupported without cURL, matching Guzzle's
native stream handler. A configured proxy is never bypassed to avoid an error.
An injected Transport or PSR-18 HTTP client keeps its own connection, private-CA,
proxy and timeout policy. Configure its total deadline on that client. Passing
requestTimeoutSeconds or workerRequestTimeoutSeconds together with an injected
client/transport is rejected instead of replacing its configuration. For example:
$http = new \GuzzleHttp\Client([
'verify' => '/etc/company/runtime-ca.pem',
'timeout' => 5.0,
]);
$client = new Client(
'https://runtime.internal',
transport: new \DurableWorkflow\Transport\Psr18Transport($http),
authentication: $rotatingCredentials,
);
Guzzle 7's native stream handler measures idle time and does not supply a total slow-body deadline. Use cURL or another HTTP client with a verified total deadline when injecting Guzzle 7. The SDK does not alter an injected client's TLS or proxy settings to impose a different timeout.
Runtime-owned external payloads
The default transport discovers the namespace's inline threshold, upload limit,
and storage availability through /api/cluster/info. It uploads larger encoded
Avro blobs to the same runtime before sending the ordinary request, preserving
the client or worker credential role. Discovery is cached separately per role
for up to 60 seconds; withNamespace() starts with fresh discovery. Batches that
would exceed the ordinary JSON request limit also use external references.
Unavailable storage, oversized values, or invalid upload references fail before
the state-bearing request is sent. Retrying uploads preserves the runtime's
content-addressed identity; the SDK does not retain an unbounded upload cache.
External storage does not remove operation-specific structural or namespace
quotas. For example, a signal may be uploaded successfully and still receive a
structural_limit_exceeded admission response. Upload success is not workflow
or command acceptance.
When a namespace externalizes large payloads, the client and worker download their opaque references from the same authenticated runtime before Avro decoding. No storage-provider credentials or filesystem access are needed in your application. The SDK checks the reference, response metadata, byte length and SHA-256, and does not follow redirects. Downloads are reused only within one response, never across namespaces or credential roles.
Client limits downloaded payload bytes to 64 MiB per response. Set
maxExternalPayloadBytes: to a smaller positive byte limit for a constrained
worker, or raise it deliberately for larger histories. This is a wire-byte limit,
not a bound on the memory used by decoded values. withNamespace() preserves it.
Invalid, unavailable or corrupt payloads raise ExternalPayloadException with a
stable reason; they do not fall back to JSON projections.
Custom implementations of Transport continue working for inline payloads. To
support external payloads, also implement PayloadTransport::fetchPayload():
honor the supplied byte bound, disable redirects, verify the supplied metadata
headers against the response, and return the exact response body. The built-in
Psr18Transport provides this behavior. These bodies contain the existing Avro
wire blob; do not base64-encode them a second time.
For automatic outbound uploads, custom transports additionally implement
PayloadUploadTransport::uploadPayload(). Send the supplied blob and headers
unchanged, enforce the supplied timeout, disable redirects, and bound the JSON
response to 64 KiB. Return the decoded response object; the SDK validates its
transport version and opaque reference against the uploaded size and checksum.
The two optional interfaces are independent, so existing download-only custom
transports need no source change, but do not gain outbound uploads automatically.
Check the runtime contract at deployment
$cluster = $client->clusterInfo();
printf(
"server=%s worker_protocol=%s\n",
$cluster->version,
$cluster->raw['worker_protocol']['version'] ?? 'unknown',
);
This SDK declares worker protocol 1.20 and is qualified against the 2.5 line. Runtime discovery is authoritative for what a particular endpoint advertises.
Keep the Cloud path prefix exactly as provisioned, but remove a trailing slash. The client normalizes that boundary and appends its own API paths.