PHP SDK guide

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.

Connection rule

Keep the Cloud path prefix exactly as provisioned, but remove a trailing slash. The client normalizes that boundary and appends its own API paths.