Framework-neutral PHP client and worker SDK for the Durable Workflow server
Requires
- php: ^8.1
- composer-runtime-api: ^2.0
- ext-json: *
- apache/avro: ^1.12
- guzzlehttp/guzzle: ^7.8
- psr/container: ^1.1|^2.0
- psr/http-client: ^1.0
- psr/http-factory: ^1.0
- psr/http-message: ^1.1|^2.0
- psr/log: ^1.1|^2.0|^3.0
Requires (Dev)
- phpdocumentor/phpdocumentor: ^3.7
- phpstan/phpstan: ^2.0
- phpunit/phpunit: ^10.5|^11.0
Suggests
- ext-pcntl: Required by framework console workers for graceful SIGINT and SIGTERM handling.
- laravel/framework: Enables the auto-discovered Laravel 12 and 13 service-mode bridge.
- symfony/console: Required with symfony/framework-bundle to run the Symfony 6.4, 7, and 8 worker command.
- symfony/framework-bundle: Enables the Symfony 6.4, 7, and 8 service-mode bundle.
This package is auto-updated.
Last update: 2026-08-03 11:02:31 UTC
README
The first-party, framework-neutral PHP SDK for applications and remote workers
that connect to a standalone Durable Workflow server.
It targets PHP 8.1 or newer and does not require Laravel or the embedded
durable-workflow/workflow engine.
Install
Install the package from Packagist:
composer require durable-workflow/sdk:2.0.0-rc.10@RC
This exact package is PHP SDK 2.0.0-rc.10 and is qualified with Server
2.0.0-rc.17. SDK and Server prerelease counters are independent. Earlier 2.0
prereleases and pre-1.0 SDK releases remain historical rather than alternate
supported baselines.
To install directly from the source repository before a tagged release:
composer config repositories.durable-workflow-sdk vcs https://github.com/durable-workflow/sdk-php composer require durable-workflow/sdk:dev-main
The SDK uses the official apache/avro
package for schema parsing and binary payload encoding. Guzzle is included as
the default PSR-18 transport; any PSR-18 client and PSR-17 factories can be
injected instead.
Start and inspect a workflow
Pass the Server origin or the complete Cloud runtime base URI to Client
without a terminal /api; the SDK owns and appends that path segment. Keep any
other Cloud runtime path prefix exactly as provided.
<?php declare(strict_types=1); require __DIR__.'/vendor/autoload.php'; use DurableWorkflow\Client; use DurableWorkflow\Auth\TokenAuthentication; $client = new Client( 'http://localhost:8080', new TokenAuthentication('dev-token-123'), namespace: 'default', ); $handle = $client->startWorkflow( workflowType: 'greeter', workflowId: 'greeting-1', taskQueue: 'php-workers', input: ['world'], ); $handle->signal('set-language', ['en']); var_dump($handle->query('status')); var_dump($handle->result(timeoutSeconds: 30));
WorkflowHandle distinguishes the stable workflow instance from a selected
run. Its ordinary operations follow whichever run is current after a
continue-as-new transition. The *SelectedRun() methods retain the original
run guard and fail rather than silently targeting a successor.
Control-plane administration and discovery
Client::withNamespace() returns an immutable namespace selection that keeps
the configured authentication, transport, and payload codec. Workflow
visibility results and the newly covered administrative surfaces use SDK model
types while retaining the complete server payload in each model's raw
property.
use DurableWorkflow\Model\ServiceOperationOptions; $orders = $client->withNamespace('orders-prod'); $page = $orders->listWorkflows( workflowType: 'orders.process', status: 'running', query: 'CustomerId = "42"', pageSize: 25, ); $schedulePage = $orders->listSchedules( status: 'paused', workflowType: 'reports.rollup', query: 'Region = "eu-west"', pageSize: 25, ); $attributes = $orders->listSearchAttributes(); $orders->createSearchAttribute('OrderTotal', 'double'); $orders->deleteSearchAttribute('TemporaryField'); $orders->setNamespaceExternalStorage( 'orders-prod', 's3', thresholdBytes: 2 * 1024 * 1024, config: ['bucket' => 'workflow-payloads'], ); $operation = $orders->startServiceOperation( 'payments', 'Cards', 'authorize', ['amount' => 4200, 'currency' => 'USD'], new ServiceOperationOptions(idempotencyKey: 'order-42-authorization'), ); $call = $operation->describe(); $operation->cancel('customer request'); $cluster = $orders->clusterInfo();
listSchedules() returns a typed page with schedules, nextPageToken, and
the original response in raw. It supports server-side status, workflow
type, visibility-query, page-size, and continuation-token filtering. Pass a
non-null nextPageToken back unchanged with the same namespace and filters to
read the next page. See the protocol guide for the paging and error contract.
startServiceOperation() explicitly starts an asynchronous call and returns a
ServiceOperationHandle. executeServiceOperation() honors the catalog mode,
waits for completion by default, and returns a ServiceOperationDescription.
Arguments use the client's payload codec; the default is the official Apache
Avro implementation.
Run a remote PHP worker
The preferred service-mode API discovers handler contracts from ordinary PHP
classes. Attributes name the server contract while method signatures describe
its arguments. Yielding a command from WorkflowContext creates a durable
step; replay sends the recorded value back into the generator without repeating
the external work.
<?php declare(strict_types=1); require __DIR__.'/vendor/autoload.php'; use DurableWorkflow\Attribute\Activity; use DurableWorkflow\Attribute\Query; use DurableWorkflow\Attribute\Signal; use DurableWorkflow\Attribute\Update; use DurableWorkflow\Attribute\Workflow; use DurableWorkflow\Client; use DurableWorkflow\Worker; use DurableWorkflow\Worker\ActivityContext; use DurableWorkflow\Worker\QueryContext; use DurableWorkflow\Worker\WorkflowContext; final class GreeterWorkflow { #[Workflow('greeter')] public function run(WorkflowContext $context, string $name): Generator { $greeting = yield $context->activity('greet', [$name]); return ['greeting' => $greeting]; } #[Query] public function status(QueryContext $context): array { return ['events' => count($context->history)]; } #[Signal('set-language')] public function setLanguage(string $language): void { // This declaration is reflected for admission; run() consumes signals. } #[Update] public function rename(QueryContext $context, string $name): string { return $name; } } final class GreetingActivities { #[Activity] public function greet(ActivityContext $context, string $name): string { return "hello, {$name}"; } } $client = new Client('http://server:8080', token: 'dev-token-123'); Worker::create($client, 'php-workers') ->register(GreeterWorkflow::class, GreetingActivities::class) ->run();
register() resolves class names once and validates every attributed method
before registration or polling. With no container, concrete classes with no
required constructor arguments are instantiated automatically. Pass any PSR-11
ContainerInterface as the third argument to Worker::create() when handlers
have application dependencies.
Attributed workflow classes have a replay-scoped lifecycle. Registration captures a clean handler template, then each workflow task replay, query, and update runs on a fresh shallow clone. Mutable properties on the workflow object therefore cannot cross workflow IDs, runs, or replay attempts, while constructor-injected collaborators retain their configured identity. Keep workflow-local mutable state directly on the handler; injected collaborators are shared services and must not be used to hold execution-local state. Workflow handler classes must remain cloneable.
Activity services have worker-scoped lifetimes instead: their resolved instance
is reused for activity tasks, so they can retain clients and other service
resources. The explicit registerWorkflow(), registerQuery(), and
registerUpdate() low-level methods also invoke the supplied callable as-is;
state captured by such a callable remains owned by the application. Use
attribute-based workflow registration when the SDK should provide replay-state
isolation.
Pass a PSR-3 LoggerInterface with the named logger argument; lifecycle,
retry, shutdown, and handler failures then use the application's normal logging
pipeline. The optional diagnosticListener receives the same event names and
structured context.
Signal methods are signature declarations for server admission and are not
invoked. The workflow reads their committed values with
$context->signals('set-language') during replay. Query and update methods are
executed with immutable QueryContext state.
The callable registration methods remain the intentional low-level escape hatch. For example, replay-consumed signals can be declared directly:
$worker->declareSignal( 'counter', 'increment', static fn (int $amount): mixed => null, );
Call $context->heartbeat($details) from a long-running activity. It throws
ActivityCancelled when the server requests cancellation. Worker::run()
installs SIGINT/SIGTERM handlers when pcntl is available, stops accepting new
tasks, and lets the active synchronous task settle before returning. The
managed worker also returns when any task poll reports a terminal typed outcome
such as stale_worker_registration, draining, or stopped; empty and timeout
polls remain idle. Registration also negotiates the worker heartbeat cadence.
Managed long polls are bounded by that cadence and heartbeat checks run between
workflow, activity, and query polls, so an idle polling cycle cannot silently
consume the server's registration freshness window. Invalid advertised cadence
values leave the worker's configured safe fallback in effect.
Low-level worker integrations can call pollWorkflowTaskResponse(),
pollActivityTaskResponse(), and pollQueryTaskResponse() to receive the
complete server envelope, including poll_status, reason, protocol metadata,
and any future fields. The existing task-only poll methods delegate to these
response methods and still return the leased task or null. Use
DurableWorkflow\Worker\PollResponse::isTerminal() to apply the same typed
terminal-outcome classification as the managed worker.
When a poll fails with a complete worker-protocol envelope explicitly marked as
transient, the managed worker retries that same poll with capped backoff while
keeping heartbeats and graceful shutdown responsive. Pass a
transientPollRetryObserver callback to the Worker constructor to record the
task kind, consecutive attempt, selected delay, and typed server exception.
Authentication failures, malformed responses, and generic server errors remain
fatal.
Laravel service mode
Laravel 12 and 13 auto-discover the service provider from the same SDK package. Publish the environment-backed configuration, add attributed handler services, and start the supervised Artisan command:
composer require durable-workflow/sdk:2.0.0-rc.10@RC php artisan vendor:publish --tag=durable-workflow-config php artisan durable-workflow:worker
Set DURABLE_WORKFLOW_ENDPOINT to a self-hosted Server origin or the complete
Cloud runtime base URI. Set DURABLE_WORKFLOW_NAMESPACE and
DURABLE_WORKFLOW_TASK_QUEUE, then choose shared-token or scoped authentication.
For scoped Cloud authentication, inject credentials at the process boundary:
| Laravel process | Inject | Do not inject |
|---|---|---|
| Web, queue, or other application process | DURABLE_WORKFLOW_CONTROL_TOKEN |
DURABLE_WORKFLOW_WORKER_TOKEN |
php artisan durable-workflow:worker |
DURABLE_WORKFLOW_WORKER_TOKEN |
DURABLE_WORKFLOW_CONTROL_TOKEN |
The service provider gives the injectable application client only the control
credential and creates a separate worker client only for the worker factory.
For a self-hosted deployment that uses one credential for both roles, inject
DURABLE_WORKFLOW_TOKEN instead. Supply secret values through the deployment
platform's process environment or secret store, not a generated configuration
file or a committed .env file. The published configuration contains only
env() references; vendor:publish never copies a secret value into application
source. List handler classes in
config/durable-workflow.php:
'handlers' => [ App\Workflows\GreeterWorkflow::class, App\Activities\GreetingActivities::class, ],
Laravel resolves every handler through its container, so ordinary constructor
injection works. Client and WorkflowClientInterface are injectable; prefer
the interface in application services that should be replaceable in tests.
Worker diagnostics use Laravel's PSR logger and dispatch
WorkerDiagnosticEvent through Laravel events. The event name is available in
its name property and includes lifecycle, retry, handler-failure, and shutdown
events.
In a Laravel test, DurableWorkflow::fake() replaces the injectable interface
with WorkflowClientFake and returns it for result setup and interaction
assertions:
$workflows = DurableWorkflow::fake() ->setWorkflowResult('greeting-1', ['greeting' => 'hello, Ada']); // Exercise application code, then use the framework-independent assertions. $workflows->assertWorkflowStarted('greeter', ['Ada']);
Symfony service mode
Symfony 6.4, 7, and 8 applications register the Bundle from the SDK package in
config/bundles.php:
return [ // ... DurableWorkflow\Bridge\Symfony\DurableWorkflowBundle::class => ['all' => true], ];
Configure Server or Cloud through environment processors. Attributed services
under Symfony's normal autoconfigured imports are registered as handlers. The
optional handlers list also registers classes outside those imports as
autowired services:
# config/packages/durable_workflow.yaml durable_workflow: endpoint: '%env(DURABLE_WORKFLOW_ENDPOINT)%' namespace: '%env(DURABLE_WORKFLOW_NAMESPACE)%' task_queue: '%env(DURABLE_WORKFLOW_TASK_QUEUE)%' credentials: control_token: '%env(default::DURABLE_WORKFLOW_CONTROL_TOKEN)%' worker_token: '%env(default::DURABLE_WORKFLOW_WORKER_TOKEN)%' handlers: - App\Workflow\GreeterWorkflow - App\Activity\GreetingActivities
Inject DURABLE_WORKFLOW_CONTROL_TOKEN only into web and other application
processes. Inject DURABLE_WORKFLOW_WORKER_TOKEN only into the process running
php bin/console durable-workflow:worker; the default:: processors leave the
opposite scoped credential unset. The Bundle binds the public autowired client
to the control credential and gives its private worker client only the worker
credential. Self-hosted deployments can instead set credentials.token from
DURABLE_WORKFLOW_TOKEN and inject that shared credential into both
processes. Keep values in the deployment platform's environment or secret store,
not YAML, generated container files, or committed environment files.
Run php bin/console durable-workflow:worker. Client and
WorkflowClientInterface are public autowired services. Handler services retain
normal Symfony autowiring, worker messages use the standard PSR logger when it
is installed, and WorkerDiagnosticEvent is dispatched through Symfony's event
dispatcher under the diagnostic name. A KernelTestCase can use
InteractsWithDurableWorkflow::fakeDurableWorkflow() to replace the autowired
interface with the same assertion-capable fake used by plain PHP and Laravel.
Both console commands accept --queue and --poll-timeout. They require
ext-pcntl so SIGINT and SIGTERM always request a graceful worker shutdown.
Configuration errors, an unreachable endpoint, rejected credentials, and
worker-protocol or contract mismatches are reported with remediation specific
to the failing boundary. Neither bridge stores workflow state or installs the
embedded Laravel workflow engine.
Test workflow code and interactions
The testing namespace has no PHPUnit dependency. Its assertions throw
DurableWorkflow\Testing\AssertionFailed, so they work with PHPUnit, Pest, or
plain PHP. Application services can type their dependency as
WorkflowClientInterface; both the network Client and WorkflowClientFake
implement that interface and return handles with the same interaction methods.
use DurableWorkflow\Testing\WorkerTestHarness; use DurableWorkflow\Testing\WorkflowClientFake; $worker = Worker::create($client, 'php-workers') ->register(GreeterWorkflow::class, GreetingActivities::class); $handlers = new WorkerTestHarness($worker); $handlers->assertWorkflowEmits('greeter', 'schedule_activity', ['Ada']); $handlers->assertActivityResult('greet', 'hello, Ada', ['Ada']); $handlers->assertQueryResult('greeter', 'status', ['events' => 0]); $handlers->assertUpdateResult('greeter', 'rename', 'Grace', ['Grace']); $handlers->assertRegistered('signal', 'set-language', 'greeter'); $workflows = (new WorkflowClientFake()) ->setQueryResult('greeting-1', 'status', 'running') ->setUpdateResult('greeting-1', 'rename', 'accepted') ->setWorkflowResult('greeting-1', ['greeting' => 'hello, Ada']); $handle = $workflows->startWorkflow('greeter', 'greeting-1', 'php-workers', ['Ada']); $handle->signal('set-language', ['en']); $handle->query('status'); $handle->update('rename', ['Grace']); $handle->result(); $workflows->assertWorkflowStarted('greeter', ['Ada']); $workflows->assertSignalSent('greeting-1', 'set-language', ['en']); $workflows->assertQueryRequested('greeting-1', 'status'); $workflows->assertUpdateRequested('greeting-1', 'rename', ['Grace']); $workflows->assertResultRequested('greeting-1');
Workflow tasks additionally require an acknowledged lease renewal before user code runs. Typed transient renewal pressure is retried with the original task ID, attempt, and lease owner; shutdown or a terminal/lost lease prevents task execution and completion.
See examples/, the generated
PHP API reference, and
docs/protocol.md for the complete client, schedule,
namespace, visibility, search-attribute, service-operation, discovery,
authentication, worker, query, and update surfaces.
Development
composer install
composer validate --strict
composer test
composer analyse
composer docs
The dependency-boundary check rejects Laravel, Illuminate, the embedded workflow package, and the standalone server package in both declared and resolved production dependencies.