Skip to content

Commit 34a0b0b

Browse files
deploy: 83cb0b4
1 parent 5858e94 commit 34a0b0b

271 files changed

Lines changed: 1664 additions & 1028 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

2.0/llms-full.txt

Lines changed: 271 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11577,6 +11577,10 @@ The invocable carrier rollout boundary is published in the same manifest:
1157711577

1157811578
- [External Execution Surface](./external-execution.md) — the carrier-neutral
1157911579
product boundary that this carrier implements.
11580+
- [PHP Invocable Activity Handler](./invocable-php-handler.md) — the PHP
11581+
helper an external process uses to parse input envelopes, dispatch
11582+
activity callables, and emit the structured result envelopes this carrier
11583+
expects.
1158011584
- [Server Config Reference](./server-config-reference.md) — environment
1158111585
variables for `DW_EXTERNAL_EXECUTOR_CONFIG_PATH` and
1158211586
`DW_EXTERNAL_EXECUTOR_CONFIG_OVERLAY`.
@@ -13319,6 +13323,273 @@ When adding a new CLI or SDK operation, keep the contract language-neutral:
1331913323
That checklist keeps `dw`, Python, and future clients aligned around the same
1332013324
HTTP and JSON protocol instead of parallel language-specific APIs.
1332113325

13326+
<!-- Source: docs/polyglot/invocable-php-handler.md -->
13327+
13328+
# PHP Invocable Activity Handler
13329+
13330+
The [invocable HTTP carrier](./invocable-carrier.md) lets the server POST a
13331+
leased activity task to an HTTPS endpoint instead of waiting for a long-poll
13332+
worker. The PHP helper `Workflow\V2\Support\InvocableActivityHandler` is the
13333+
reference implementation that an external PHP process uses to turn that
13334+
request into a result envelope the server can reconcile.
13335+
13336+
Use it to wire an activity handler into:
13337+
13338+
- an AWS Lambda or Google Cloud Function invoked over HTTPS
13339+
- a Laravel controller sitting behind a thin HTTP service
13340+
- a container exposing one POST endpoint per activity queue
13341+
13342+
The helper parses the carrier-neutral external task input envelope, looks up
13343+
the registered callable, enforces the lease deadline, and emits the
13344+
carrier-neutral external task result envelope — including the failure shapes
13345+
the server expects.
13346+
13347+
## Quick Start
13348+
13349+
Install the Durable Workflow package in the external process, register one
13350+
callable per activity handler name, and hand the request body to
13351+
`handle()`:
13352+
13353+
```php
13354+
use Workflow\V2\Support\InvocableActivityHandler;
13355+
13356+
$handler = new InvocableActivityHandler([
13357+
'billing.charge-card' => static function (int $amount, string $currency): array {
13358+
// Real charge-card logic. Must be idempotent per task id.
13359+
return [
13360+
'approved' => true,
13361+
'amount' => $amount,
13362+
'currency' => $currency,
13363+
];
13364+
},
13365+
]);
13366+
13367+
$envelope = json_decode(file_get_contents('php://input'), associative: true);
13368+
$result = $handler->handle($envelope);
13369+
13370+
header('Content-Type: application/vnd.durable-workflow.external-task-result+json');
13371+
echo json_encode($result, JSON_UNESCAPED_SLASHES);
13372+
```
13373+
13374+
The handler receives the input envelope and returns the result envelope.
13375+
The carrier contract (HTTPS, POST, auth, timeouts, retry budget) is owned by
13376+
the invocable HTTP carrier. The PHP helper owns argument decoding, handler
13377+
dispatch, deadline enforcement, result encoding, and the failure taxonomy.
13378+
13379+
## Registering Handlers
13380+
13381+
The first constructor argument is a map keyed by the `task.handler` value the
13382+
server sends on the input envelope. That value is the `handler` field on the
13383+
matching entry in the external executor config:
13384+
13385+
```php
13386+
new InvocableActivityHandler(
13387+
handlers: [
13388+
'billing.charge-card' => [$billingService, 'chargeCard'],
13389+
'billing.refund' => [$billingService, 'refund'],
13390+
'ops.rotate-key' => static fn (string $keyId): array => $keys->rotate($keyId),
13391+
],
13392+
carrier: 'billing-lambda',
13393+
resultCodec: 'avro',
13394+
);
13395+
```
13396+
13397+
An input with a `task.handler` that is not registered produces a
13398+
`failed` result with `failure.kind = application`,
13399+
`classification = application_error`, and
13400+
`type = UnknownActivityHandler`. The configured activity retry policy still
13401+
applies on the server side.
13402+
13403+
The optional `carrier` name is echoed in `metadata.carrier` on every result
13404+
envelope so operators can tell which external runtime produced the response.
13405+
Use a stable, redaction-safe identifier (`billing-lambda`,
13406+
`ops-cloud-run`, `laravel-admin-api`).
13407+
13408+
The optional `resultCodec` controls how the helper serializes the return
13409+
value into `result.payload.blob`. It defaults to `avro`, which matches the
13410+
codec that PHP workers already use for durable payloads. The result codec
13411+
must be one the server's `CodecRegistry` knows about; `protobuf` is not
13412+
accepted and fails fast in the constructor.
13413+
13414+
## Result Envelope
13415+
13416+
On success, `handle()` returns the carrier-neutral success envelope:
13417+
13418+
```json
13419+
{
13420+
"schema": "durable-workflow.v2.external-task-result",
13421+
"version": 1,
13422+
"outcome": {
13423+
"status": "succeeded",
13424+
"recorded": true
13425+
},
13426+
"task": {
13427+
"id": "acttask_01HV7D3G3G61TAH2YB5RK45XJS",
13428+
"kind": "activity_task",
13429+
"attempt": 1,
13430+
"idempotency_key": "attempt_01HV7D3KJ1C8WQNNY8MVM8J40X"
13431+
},
13432+
"result": {
13433+
"payload": {
13434+
"codec": "avro",
13435+
"blob": "<base64-encoded payload>"
13436+
},
13437+
"metadata": {
13438+
"content_type": "application/vnd.durable-workflow.result+json"
13439+
}
13440+
},
13441+
"metadata": {
13442+
"handler": "billing.charge-card",
13443+
"carrier": "billing-lambda",
13444+
"duration_ms": 42
13445+
}
13446+
}
13447+
```
13448+
13449+
The `task.id`, `task.attempt`, and `task.idempotency_key` fields are copied
13450+
from the input envelope so the server can reconcile the result with the
13451+
original lease.
13452+
13453+
On failure, `handle()` returns the failure envelope. The `failure` block
13454+
carries the kind, classification, message, originating PHP type, stack
13455+
trace, and whether the failure is retryable. A deadline failure also
13456+
includes the `deadline` name and `expires_at` value:
13457+
13458+
```json
13459+
{
13460+
"schema": "durable-workflow.v2.external-task-result",
13461+
"version": 1,
13462+
"outcome": {
13463+
"status": "failed",
13464+
"retryable": true,
13465+
"recorded": true
13466+
},
13467+
"task": {"id": "...", "kind": "activity_task", "attempt": 1, "idempotency_key": "..."},
13468+
"failure": {
13469+
"kind": "timeout",
13470+
"classification": "deadline_exceeded",
13471+
"message": "Invocable activity task received after lease.expires_at.",
13472+
"type": "ExternalTaskDeadlineExceeded",
13473+
"stack_trace": null,
13474+
"timeout_type": "deadline_exceeded",
13475+
"cancelled": false,
13476+
"details": {
13477+
"deadline": "lease.expires_at",
13478+
"expires_at": "2026-04-22T15:14:02.000000Z"
13479+
}
13480+
},
13481+
"metadata": {"handler": "billing.charge-card", "carrier": "billing-lambda", "duration_ms": 3}
13482+
}
13483+
```
13484+
13485+
## Failure Taxonomy
13486+
13487+
| `failure.kind` | `classification` | Retryable | When it fires |
13488+
| --- | --- | --- | --- |
13489+
| `timeout` | `deadline_exceeded` | yes | Lease or input deadline already expired when the envelope arrived, or the handler returned after one expired during execution. |
13490+
| `decode_failure` | `decode_failure` | no | Arguments could not be decoded with the declared codec, a deadline string was unparseable, the handler raised `TypeError` / `ValueError` on its parameters, or the success payload could not be re-encoded with the configured result codec. |
13491+
| `application` | `application_error` | depends on thrown exception | The handler threw. `retryable` is `false` when the thrown exception implements `Workflow\Exceptions\NonRetryableExceptionContract`; otherwise `true`. The message is the exception message and `type` is the exception class. |
13492+
| `application` | `application_error` | no | `task.kind` is not `activity_task`, or `task.handler` is not registered in the map. |
13493+
13494+
The helper never returns a bare exception. Every code path produces a
13495+
structured result envelope so the invocable carrier can reconcile the
13496+
response deterministically.
13497+
13498+
## Deadlines And Idempotency
13499+
13500+
The input envelope carries the active `lease.expires_at` plus the declared
13501+
activity deadlines (`schedule_to_start`, `start_to_close`,
13502+
`schedule_to_close`, `heartbeat`). The helper checks all of them:
13503+
13504+
- Before dispatching the handler, any expired deadline short-circuits into a
13505+
`timeout` failure with `details.deadline` naming which field expired. The
13506+
registered callable is not invoked.
13507+
- After the handler returns, the helper re-checks the deadlines. A handler
13508+
that ran for longer than its lease produces the same `timeout` failure
13509+
and the return value is dropped from the envelope.
13510+
13511+
Because the carrier retries transport delivery and the runtime redelivers
13512+
leases that were not reported, the same `task.id` and
13513+
`task.idempotency_key` can arrive more than once. Handler code must be
13514+
idempotent. The idempotency key on every envelope is stable across retries
13515+
for the same attempt.
13516+
13517+
## Payload Codecs And External Storage
13518+
13519+
Argument payloads arrive on `payloads.arguments` with a `codec` field. The
13520+
helper uses the server's `CodecRegistry` to decode them, so the external
13521+
process must reference the matching codec implementation (`avro`, `json`,
13522+
etc.). A mismatched codec or a malformed blob becomes a
13523+
`decode_failure`.
13524+
13525+
When workflow inputs exceed the configured
13526+
[external payload storage](../features/external-payload-storage.md)
13527+
threshold, the server stores the bytes in the configured driver and sends
13528+
a reference envelope instead of an inline blob. Pass an
13529+
`ExternalPayloadStorageDriver` into the constructor so the helper can
13530+
resolve references before calling the handler:
13531+
13532+
```php
13533+
use Workflow\V2\Contracts\ExternalPayloadStorageDriver;
13534+
use Workflow\V2\Support\InvocableActivityHandler;
13535+
13536+
$handler = new InvocableActivityHandler(
13537+
handlers: $handlers,
13538+
carrier: 'billing-lambda',
13539+
resultCodec: 'avro',
13540+
externalStorage: $driver,
13541+
);
13542+
```
13543+
13544+
The driver must satisfy the same contract the server uses to write the
13545+
payload so the reference, hash, and codec all match. A reference that the
13546+
external process cannot resolve produces a `decode_failure` instead of a
13547+
silent empty payload.
13548+
13549+
## Production Wiring
13550+
13551+
A Laravel controller that hosts the handler behind a single POST route
13552+
looks like this:
13553+
13554+
```php
13555+
use Illuminate\Http\JsonResponse;
13556+
use Illuminate\Http\Request;
13557+
use Workflow\V2\Support\InvocableActivityHandler;
13558+
13559+
final class BillingActivityController
13560+
{
13561+
public function __construct(private readonly InvocableActivityHandler $handler) {}
13562+
13563+
public function __invoke(Request $request): JsonResponse
13564+
{
13565+
$result = $this->handler->handle($request->all());
13566+
13567+
return new JsonResponse(
13568+
data: $result,
13569+
headers: ['Content-Type' => 'application/vnd.durable-workflow.external-task-result+json'],
13570+
);
13571+
}
13572+
}
13573+
```
13574+
13575+
Route the external executor config at the HTTPS URL that fronts this
13576+
controller and declare an `auth_ref` so the route is authenticated. The
13577+
loopback HTTP exemption only applies to developer iteration.
13578+
13579+
## Related Surfaces
13580+
13581+
- [Invocable HTTP Carrier](./invocable-carrier.md) — the server-side carrier
13582+
contract that delivers input envelopes and reconciles result envelopes.
13583+
- [External Execution Surface](./external-execution.md) — the carrier-neutral
13584+
product boundary, including the input and result envelope schemas the
13585+
helper implements.
13586+
- [External Payload Storage](../features/external-payload-storage.md) — how
13587+
oversized arguments or results are offloaded to a configured driver and
13588+
represented as a verifiable reference envelope.
13589+
- [Worker Protocol](./worker-protocol.md) — the broader worker-plane contract
13590+
that publishes the invocable carrier contract alongside poll-based handler
13591+
shapes.
13592+
1332213593
<!-- Source: docs/polyglot/worker-protocol.md -->
1332313594

1332413595
# Worker Protocol

0 commit comments

Comments
 (0)