Workers
A worker is a long-running PHP process that advertises type names, polls one task queue, replays workflows, runs activities, and heartbeats its registration.
Register every handler before polling
Choose one registration style for each handler. Attribute discovery is the preferred class-oriented path; direct registration is useful when an application already produces callables.
register() validates all supplied services before the worker contacts the
runtime. Passing an un-attributed class raises InvalidWorkerDefinition before
registration or polling begins. Add a Durable Workflow handler attribute, or
use the direct callable API instead.
Handler registration configures this PHP object. run() separately registers
the worker with the runtime before its first poll.
Add the other handler types to the same worker before starting its loop:
$worker = new Worker(
client: $client,
taskQueue: 'orders',
buildId: getenv('APP_RELEASE') ?: null,
);
$worker
->declareSignal('orders.process', 'approve')
->registerQuery('orders.process', 'status', $statusQuery)
->registerUpdate('orders.process', 'change-address', $addressUpdate);
$worker->run();
Workflow and activity type names are protocol identities. Keep them stable even when PHP class names or namespaces change. Duplicate registrations fail during bootstrap rather than becoming order-dependent.
One task queue is one routing boundary
Scale a queue by starting more workers with the same registered types and a compatible build ID. Split queues when work needs different dependencies, resource limits, deployment cadence, or trust boundaries.
Do not send a workflow to a queue that has no compatible workflow handler. Inspect listWorkers() and describeTaskQueue() before assuming a deployment received traffic.
An activity option such as ['queue' => 'payments-v2'] selects the queue of a newly scheduled activity execution. The runtime stores that resolved queue. Pending activities, their retries and repaired tasks keep it. Changing a worker's taskQueue does not move recorded work, and existing workflow runs keep their own workflow queue. Retain compatible workers on old queues while those dependencies remain. Follow the recorded-queue transition and diagnostics procedure.
Let the managed loop own the protocol
Worker::run() registers, negotiates heartbeat cadence, polls workflow/activity/query tasks, renews workflow-task leases, and reports terminal outcomes. Typed transient poll pressure is retried with capped backoff while registration heartbeats and shutdown checks remain responsive.
Activity continuations
Activity continuations are an explicit constructor opt-in:
$worker = new Worker(
client: $client,
taskQueue: 'orders',
enableActivityContinuation: true,
);
$worker->register(OrderWorkflow::class, OrderActivities::class);
$worker->run();
The default is false. A supporting native Server advertises
activity_workflow_continuation. After a successful activity, the worker may
replay the run's bounded history with that result and propose the next workflow
commands on its completion. The Server validates the authoritative history and
original authority, then commits the result and eligible commands together.
An exact completion retry recovers the original decision.
The initial contract supports sequential remote activities, timers, workflow completion and version markers. Local activities, parallel groups, selections, cancellation scopes, message cursors/waits and update/cancellation state retain ordinary execution. If workflow code reads business time at or after the speculative result, the worker abandons speculation because only the Server can assign that event's time. It does not repeat the activity callback.
Missing or older capability, unsupported replay state and a documented Server decline preserve ordinary completion and polling. Backend faults and unknown completion outcomes follow completion error/recovery rules. They do not become eligibility declines. Drain or disable opted-in workers before downgrading a producer or introducing older serving nodes. No speedup is promised.
Poll timeouts and early disconnects
With the default cURL transport, supporting Servers send progress during idle worker polls. Progress keeps an idle connection active while the complete request remains bounded to the requested wait plus five seconds. Ordinary polls honor a shorter injected Guzzle timeout. Cooperative polls retain their explicit request budget. Older Servers continue returning ordinary JSON. Opaque PSR-18 clients retain ordinary JSON and their configured policies.
Keep proxy timeouts longer than the configured poll wait. A worker warning
named worker.poll_ended_early reports the observed duration and configured
wait when a connection failure or gateway cutoff ends a poll early. Inspect
the proxy and client timeout settings named in its remediation. Configure the
worker's PSR logger or diagnostic listener to collect these warnings.
Large payloads on slower connections
Cooperative workers supervise external payload downloads while bytes arrive and while the connection waits. Worker presence and workflow lease renewal use short control requests. Activity status checks observe ownership and cancellation without recording application heartbeats. Query leases and original cancellation cleanup deadlines continue to limit loading.
The default transport allows 30 seconds for each supervised payload download. Select a larger finite transfer timeout when your connection needs it:
$client = new Client(
baseUri: $serverUrl,
transport: new Psr18Transport(payloadTransferTimeoutSeconds: 180),
);
The transfer timeout accepts 1 through 900 seconds. It does not extend a run,
activity, query, explicit request, or cancellation cleanup budget. Control RPCs
retain their bounded timeouts. A partial, unauthorized, or invalid body is
discarded before handler dispatch. Custom transports can implement the optional
BoundedProgressPayloadTransport interface to provide the same supervised
streaming support.
After a transient registration-heartbeat failure, the managed loop refreshes that existing registration before acquiring another task. An already acquired task continues without waiting for that retry. Server fencing, stopped and draining responses remain authoritative, and the worker does not register again to bypass them.
Query execution authority
Query loading, preparation and result submission share the original query
deadline. On protocol 1.20 runtimes that advertise query_task_authority, a
bounded transport also observes the original claim without renewing its lease.
The worker checks ownership before entering the handler and discards a late
result. Registration heartbeats do not extend query authority.
Require this mode before registration when your application needs it:
$worker = Worker::create(
$client,
'orders',
requireQueryTaskAuthority: true,
);
This option refuses an unsupported runtime, protocol or transport before
registration. The default Psr18Transport supports bounded requests with Guzzle
and cURL. Custom transports must provide bounded requests and supervised payload
progress to bound external loading as well.
Without the option, protocol 1.19 and unbounded transports keep their compatible
mode. The worker enforces timing actually supplied in the delivery and reports
worker.query_authority_unavailable when live observation is unavailable. A
legacy delivery without timing cannot establish the original caller deadline.
Protocol 1.19 never calls the status endpoint.
Synchronous PHP handlers already running cannot be safely interrupted. Keep query
handlers short and avoid side effects. The worker checks authority again before
submitting their result. Transport timeouts use whole seconds. If the remaining
budget cannot cover bounded control I/O, the worker abandons that claim without
extending its deadline. Direct Client completion and failure calls retain the
Server's receipt and terminal-state semantics.
Keep run() inside your CLI command or supervised process:
$worker->run(pollTimeoutSeconds: 1);
The supervisor starts the process and delivers shutdown signals. The managed loop owns registration, heartbeat state and deregistration. Laravel and Symfony applications can use their supplied Laravel or Symfony worker commands.
tick() performs one polling iteration. It does not establish or close that
worker lifecycle. A fresh worker using tick() directly has no managed
registration; calling a low-level client registration method does not initialize
the Worker's heartbeat or cleanup state. Use run() for application workers.
The lower-level poll*TaskResponse() APIs are for adapters that intentionally own lease, attempt, completion, and failure semantics. Application workers should prefer the managed loop.
Supervise the process
Run one process per container or a bounded number under systemd, Supervisor, Kubernetes, Laravel process management, or Symfony Process. A production supervisor should:
- restart unexpected exits with backoff;
- deliver
SIGTERMand allow a drain window; - report the worker build ID and task queue;
- cap memory and recycle processes deliberately;
- keep client and worker credentials separate.
Workers need a real process lifecycle. Start them through your platform's process supervisor, not a controller, queue callback, or request shutdown hook.
Recover from a stale registration
If the worker process stalls longer than the Server's registration-freshness
window (a host pause, a long garbage-collection stop or a network partition),
the Server fences its registration. run() then drops every task it held,
registers again and keeps polling. The Server redelivers the dropped tasks.
Repeated recoveries back off up to 60 seconds. Each one logs
worker.reregistered and increments durable_workflow_worker_reregistrations.
Construct the worker with stopOnStaleRegistration: true to stop instead and
let your supervisor restart it.
Observe worker health
Use Server or Cloud visibility to verify registration freshness, build compatibility, and queue depth. Treat “the process exists” and “the worker is admitted and polling” as different health checks.