Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 14 additions & 1 deletion .github/workflows/service-mode-published-smoke.yml
Original file line number Diff line number Diff line change
Expand Up @@ -268,11 +268,24 @@ jobs:
fi

- name: Check out only the smoke runner
if: inputs.cooperative_server_image != ''
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
with:
persist-credentials: false

- name: Verify PHP 3 workflow instance lifetime from the installed package
env:
SDK_VERSION: ${{ inputs.sdk_version }}
LIFETIME_RUNTIME_URL: ${{ env.DURABLE_WORKFLOW_SERVER_URL }}
LIFETIME_NAMESPACE: ${{ env.DURABLE_WORKFLOW_NAMESPACE }}
run: |
set -euo pipefail
if (( ${SDK_VERSION%%.*} < 3 )); then
echo 'The fresh workflow instance contract starts with SDK 3.'
exit 0
fi
consumer="$(cat "$RUNNER_TEMP/service-mode-consumer")"
php tests/smoke/workflow-lifetime.php "$consumer/vendor/autoload.php"

- name: Verify published cooperative discovery with a scoped worker token
if: inputs.cooperative_server_image != ''
env:
Expand Down
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,23 @@ project follows [Semantic Versioning](https://semver.org/).

## [Unreleased]

## [3.0.0] - 2026-10-20

### Changed

- Construct a fresh attributed workflow handler for every execution, replay,
query and update. Register workflow classes or an explicit `WorkflowFactory`
instead of prebuilt workflow objects. PSR-11 workflow bindings must be
transient; Laravel and Symfony retain shared injected collaborator identities.
- Run workflow constructors and factories during replay. Keep external side
effects in activities. Activity services and low-level callable lifetimes
retain their application-owned behavior.

### Fixed

- Isolate constructor-created nested mutable state across workflow IDs, runs
and replay attempts. Reject reused or changing factory results before polling
and report later construction failures as recoverable replay errors.
- Retry expired idle worker I/O budgets with backoff and restore registration
before acquiring more work. Task and cancellation authority deadlines retain
their original bounds.
Expand Down
9 changes: 8 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,19 @@ injection, commands, logging, and tests.
## Install

```bash
composer require durable-workflow/sdk:^2.0
composer require durable-workflow/sdk
```

The SDK requires PHP 8.1 or newer. It uses the official `apache/avro` package
for portable payloads and accepts any PSR-18 HTTP client.

Upgrading from PHP SDK 2 requires a workflow registration migration. Register
workflow classes or a `WorkflowFactory` that constructs a fresh handler instead
of passing prebuilt workflow objects. Workflow container bindings must be
transient, while injected collaborators may remain shared. Constructors run
during replay and must be free of external side effects. See the
[registration migration guide](https://php.durable-workflow.com/build/workflows-activities/#migrate-workflow-registration-from-php-sdk-2).

## Choose Your Path

| Starting point | Recommended guide |
Expand Down
2 changes: 1 addition & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@
}
},
"durable-workflow": {
"product-train": "2.5.4",
"product-train": "3.0.0",
"supported-server-versions": "2.5.0",
"worker-protocol-version": "1.20",
"control-plane-version": "2",
Expand Down
41 changes: 33 additions & 8 deletions docs/portal/build/workflows-activities.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,14 +69,39 @@ Create execution-local variables and temporary mutable objects inside the
workflow method. Workflow replay reconstructs durable values through `WorkflowContext`.
Apply the same rule to queries and updates.

The released SDK shallow-clones attributed workflow handlers without rerunning
their constructors. Constructor-created nested objects and referenced
properties can remain shared across workflows and replay attempts. Do not use
them, injected services, static properties or captured shared objects for
execution-local mutable state. The limitation is tracked in
[#16](https://github.com/durable-workflow/sdk-php/issues/16). The proposed
[fresh-construction correction](https://github.com/durable-workflow/sdk-php/pull/111)
is not released.
Attributed workflow handlers also receive a new instance for every task replay,
query and update. Create nested execution-local objects inside the constructor
or factory. Injected services retain their configured identities and must not
hold execution-local mutable state. Static properties and objects captured
outside a factory remain shared.

Constructors and factories run repeatedly. Keep them free of external side
effects; perform those effects in activities.

### Migrate workflow registration from PHP SDK 2

Register the workflow class when the SDK or container can construct it. Replace
prebuilt workflow objects with an explicit construction recipe when dependencies
need custom wiring:

```php
use DurableWorkflow\Worker\WorkflowFactory;

$worker->register(new WorkflowFactory(
static fn (): OrderWorkflow => new OrderWorkflow($sharedClient),
));
```

PSR-11 workflow bindings must return a new workflow on every resolution. Keep
collaborators such as API clients shared where the application requires it.
Laravel workflow bindings must be transient. Symfony's autoconfigured workflow
services become non-shared while their collaborators keep their configured
lifetimes. Activities and the low-level callable API retain their existing
lifetimes. Reused factory instances fail registration; a factory that becomes
invalid after startup produces a recoverable replay error.

The upgrade does not rewrite committed history. Preserve the workflow's recorded
command sequence and verify representative existing histories before deployment.

## Call a registered service

Expand Down
6 changes: 5 additions & 1 deletion docs/portal/frameworks/laravel.md
Original file line number Diff line number Diff line change
Expand Up @@ -267,7 +267,11 @@ Register both classes in the published configuration:
```

An empty handler list is a startup error. The worker resolves every configured
class through the Laravel container before polling. Dependencies read by a
class through the Laravel container before polling. Each workflow replay,
query and update resolves a fresh workflow instance. Use transient workflow
bindings, such as `bind()`, rather than `singleton()` or `instance()`.
Injected collaborators may retain their configured shared lifetime. Workflow
constructors must be free of external side effects. Dependencies read by a
workflow must be deterministic and stable across replay; put database, network,
clock, and other side-effecting dependencies in activities.

Expand Down
6 changes: 6 additions & 0 deletions docs/portal/frameworks/symfony.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,12 @@ final class FulfillOrderWorkflow

Add an autowired activity service with `#[Activity('orders.reserve')]`. The Bundle tags both services during container compilation, and the worker resolves their ordinary constructor dependencies. Use the explicit `handlers` list only for classes outside Symfony's autoconfigured imports.

Attributed workflow services are non-shared. Every workflow replay, query and
update constructs a fresh handler while injected collaborators keep their
configured lifetimes. Keep workflow constructors free of external side effects.
When constructing `WorkerFactory` manually, supply a `WorkflowFactory` for a
workflow with custom dependencies instead of passing a prebuilt workflow object.

Autowired workflow services use the same ordered barrier for concurrent work:

```php
Expand Down
22 changes: 10 additions & 12 deletions docs/protocol.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,18 +100,16 @@ interleaved workflow executions cannot consume one another's execution state.
Generator-returning workflow handlers are not part of the 2.0 service-mode
contract.

Attribute-discovered workflow entry points, queries, and updates receive a
shallow clone of the registered workflow handler template for every invocation.
The SDK does not rerun the constructor. Nested objects and referenced properties can remain
shared under [PHP's cloning rules](https://www.php.net/manual/en/language.oop5.cloning.php),
including constructor-created state and injected collaborators. This does not
provide complete workflow-state isolation. Keep execution-local state in method
variables and construct temporary objects inside each invocation. The remaining
handler-lifetime defect is tracked in [#16](https://github.com/durable-workflow/sdk-php/issues/16).

Activity handlers use the resolved activity service instance directly across
tasks. Low-level callable registrations keep the lifetime and captured state
of the callable supplied by the application.
Attribute-discovered workflow entry points, queries, and updates receive a newly
constructed handler for every invocation. Class registration uses a fresh
constructor or transient container resolution. Custom construction uses an
explicit `WorkflowFactory`; it creates local mutable state while retaining
configured injected services. Existing workflow objects and singleton workflow
bindings require a factory migration rather than shallow cloning. Invalid
factories are rejected before polling, or produce a recoverable replay error if
they become invalid later. Activity handlers use their resolved service instance
across tasks. Low-level callable registrations keep the lifetime and captured
state of the callable supplied by the application.

The runtime fetches all paginated history before replay. Activity handlers can
heartbeat and observe cancellation. Query handlers receive immutable committed
Expand Down
6 changes: 3 additions & 3 deletions docs/quickstart-contract.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@
"schema_version": 2,
"package": {
"name": "durable-workflow/sdk",
"published_version": "2.5.4",
"composer_requirement": "2.5.4",
"onboarding_requirement": "^2.5"
"published_version": "3.0.0",
"composer_requirement": "3.0.0",
"onboarding_requirement": "^3.0"
},
"runtime_targets": {
"server": {
Expand Down
52 changes: 39 additions & 13 deletions docs/sdk-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -434,24 +434,50 @@ Worker::create($client, 'php-workers')
->run();
```

`register()` resolves class names once and validates every attributed method
`register()` resolves class names 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.

Registration retains a workflow handler template. Each attributed workflow task
replay, query, and update runs on a shallow clone of it. The constructor is not
rerun, and shallow cloning does not automatically isolate nested objects or referenced properties.
Mutating constructor-created object state can therefore affect another workflow
ID, run or replay. This limitation remains open in
[#16](https://github.com/durable-workflow/sdk-php/issues/16).

Keep workflow-local state in method variables. Create temporary mutable objects
inside each workflow, query or update invocation. During workflow replay,
reconstruct durable values through `WorkflowContext`. Injected collaborators retain their configured
identity and must not hold execution-local state. Workflow handler classes must
remain cloneable. The proposed fresh-construction API in #111 is not released.
Attributed workflow classes have a replay-scoped lifecycle. Each workflow task
replay, query, and update constructs a new handler. Constructor-created nested
state belongs to that invocation, including mutable objects held by readonly
properties. Constructors and container factories must be free of external side
effects because construction repeats during replay. Perform side effects in
activities.

With a PSR-11 container, workflow bindings must return a new instance on every
resolution. Dependencies such as API clients, loggers, and configuration may
remain shared. Laravel's bridge resolves workflows for each invocation. The
Symfony bridge makes attributed workflow services non-shared while retaining
their dependencies' configured lifetimes.

Use `WorkflowFactory` for custom construction or a container that exposes only
singleton workflow services:

```php
use DurableWorkflow\Worker\WorkflowFactory;

$sharedClient = $container->get(ApplicationApiClient::class);
$worker->register(new WorkflowFactory(
static fn (): OrderWorkflow => new OrderWorkflow($sharedClient),
));
```

Create execution-local state inside the workflow constructor or factory. Do not
capture a mutable workflow-state object outside the factory and share it among
the new handlers. Injected services retain their identity and must not hold
execution-local state.

**Registration migration:** replace `register($workflowObject)` with class
registration or `register(new WorkflowFactory(fn () => new YourWorkflow($dependencies)))`.
The SDK cannot infer a configured object's construction recipe or distinguish
its nested state from shared services. Reused factory results and singleton
workflow bindings fail validation before polling. A factory that becomes invalid
after startup produces a recoverable `workflow_instance_factory_invalid` replay
error instead of a terminal workflow completion or failure. Workflow classes no
longer need to be cloneable.

Activity services have worker-scoped lifetimes instead: their resolved instance
is reused for activity tasks, so they can retain clients and other service
Expand Down
9 changes: 9 additions & 0 deletions regression-corpus-policy.json
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,15 @@
"function\\s+resolve\\s*\\("
]
},
{
"glob": "src/Worker/HandlerDefinition.php",
"content_patterns": [
"function\\s+replaySafe\\s*\\("
]
},
{
"glob": "src/Worker/WorkflowFactory.php"
},
{
"glob": "src/Worker.php",
"content_patterns": [
Expand Down
22 changes: 22 additions & 0 deletions scripts/ci/run-replay-regression-fixture.php
Original file line number Diff line number Diff line change
Expand Up @@ -354,6 +354,26 @@ public function run(WorkflowContext $context): array
}
}

final class ReplayRegressionAttributedNestedWorkflow
{
private readonly object $state;

public function __construct()
{
$this->state = (object) ['invocation' => 0];
}

#[WorkflowHandler('golden.attributed-nested-state')]
public function run(WorkflowContext $context): array
{
return [
'workflow_id' => $context->workflowId,
'run_id' => $context->runId,
'invocation' => ++$this->state->invocation,
];
}
}

final class ReplayRegressionConsumer
{
private const FIXTURE_SCHEMA = 'durable-workflow.replay-regression/v1';
Expand Down Expand Up @@ -516,6 +536,8 @@ private static function executeLayout(
$worker->register(ReplayRegressionAttributedStatefulWorkflow::class);
} elseif ($workflowType === '0') {
$worker->register(ReplayRegressionNumericWorkflow::class);
} elseif ($workflowType === 'golden.attributed-nested-state') {
$worker->register(ReplayRegressionAttributedNestedWorkflow::class);
} else {
$worker->registerWorkflow($workflowType, self::workflow($workflowType));
}
Expand Down
51 changes: 39 additions & 12 deletions scripts/ci/test-regression-corpus-policy.py
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,28 @@ def setUp(self) -> None:
return new $class();
}
}
"""
)
(self.root / "src/Worker/HandlerDefinition.php").write_text(
"""<?php
final class HandlerDefinition
{
public static function replaySafe(callable $factory): callable
{
return $factory;
}
}
"""
)
(self.root / "src/Worker/WorkflowFactory.php").write_text(
"""<?php
final class WorkflowFactory
{
public function create(): object
{
return ($this->factory)();
}
}
"""
)
(self.root / "vendor/autoload.php").write_text("<?php\n")
Expand Down Expand Up @@ -2197,21 +2219,16 @@ def test_official_php_runner_uses_attributed_worker_registration(self) -> None:
shutil.copytree(REPOSITORY_ROOT / "src", shared_source_root / "src")
definition_path = shared_source_root / "src/Worker/HandlerDefinition.php"
definition = definition_path.read_text()
replay_safe_resolver = """ return new self(
$contract,
static function () use ($prototype, $method): Closure {
$handler = clone $prototype;
method_start = definition.index(" public static function replaySafe(")
method_end = definition.index(" public function contract()", method_start)
shared_resolver = """ public static function replaySafe(object $prototype, string $method, WorkflowFactory $factory): self
{
return self::shared([$prototype, $method]);
}

return Closure::fromCallable([$handler, $method]);
},
);
"""
shared_resolver = (
" return new self($contract, static fn (): Closure => $contract);\n"
)
self.assertIn(replay_safe_resolver, definition)
definition_path.write_text(
definition.replace(replay_safe_resolver, shared_resolver, 1)
definition[:method_start] + shared_resolver + definition[method_end:]
)

defective_shared = self.run_official_php_runner(
Expand Down Expand Up @@ -2958,6 +2975,16 @@ def test_attributed_handler_lifetime_changes_require_replay_growth(self) -> None
"return new $class();",
"return clone new $class();",
),
"handler replay factory": (
self.root / "src/Worker/HandlerDefinition.php",
"return $factory;",
"return $this->shared;",
),
"workflow instance factory": (
self.root / "src/Worker/WorkflowFactory.php",
"return ($this->factory)();",
"return $this->shared;",
),
}

for name, (path, original, changed) in cases.items():
Expand Down
Loading
Loading