From a29db01df87f4e4e6070cd930e6df1cb68d2c334 Mon Sep 17 00:00:00 2001 From: Donald Pinckney Date: Mon, 16 Mar 2026 18:31:53 -0400 Subject: [PATCH 1/4] Add all PHP SDK reference files (11 files) Created PHP reference content for: patterns, testing, error-handling, determinism, determinism-protection, versioning, data-handling, observability, advanced-features, gotchas, and php.md (language entry). Code-first style matching Python reference. Co-Authored-By: Claude Opus 4.6 (1M context) --- references/php/advanced-features.md | 111 ++++++ references/php/data-handling.md | 232 +++++++++++ references/php/determinism-protection.md | 43 +++ references/php/determinism.md | 48 +++ references/php/error-handling.md | 131 +++++++ references/php/gotchas.md | 156 ++++++++ references/php/observability.md | 119 ++++++ references/php/patterns.md | 468 +++++++++++++++++++++++ references/php/php.md | 243 ++++++++++++ references/php/testing.md | 211 ++++++++++ references/php/versioning.md | 193 ++++++++++ 11 files changed, 1955 insertions(+) create mode 100644 references/php/advanced-features.md create mode 100644 references/php/data-handling.md create mode 100644 references/php/determinism-protection.md create mode 100644 references/php/determinism.md create mode 100644 references/php/error-handling.md create mode 100644 references/php/gotchas.md create mode 100644 references/php/observability.md create mode 100644 references/php/patterns.md create mode 100644 references/php/php.md create mode 100644 references/php/testing.md create mode 100644 references/php/versioning.md diff --git a/references/php/advanced-features.md b/references/php/advanced-features.md new file mode 100644 index 0000000..1303935 --- /dev/null +++ b/references/php/advanced-features.md @@ -0,0 +1,111 @@ +# PHP SDK Advanced Features + +## Schedules + +Create recurring workflow executions. + +```php +use Temporal\Client\Schedule\Schedule; +use Temporal\Client\Schedule\Action\StartWorkflowAction; +use Temporal\Client\Schedule\Spec\ScheduleSpec; +use Temporal\Client\Schedule\Spec\ScheduleIntervalSpec; + +$handle = $scheduleClient->createSchedule( + Schedule::new() + ->withAction(StartWorkflowAction::new('DailyReportWorkflow') + ->withTaskQueue('reports') + ) + ->withSpec(ScheduleSpec::new() + ->withIntervals(new ScheduleIntervalSpec(every: new \DateInterval('P1D'))) + ), + scheduleId: 'daily-report', +); + +// Manage schedules +$handle->pause(); +$handle->unpause(); +$handle->trigger(); // Run immediately +$handle->delete(); +``` + +## Async Activity Completion + +For activities that complete asynchronously (e.g., human tasks, external callbacks). + +```php +use Temporal\Activity; + +#[ActivityMethod] +public function requestApproval(string $requestId): void +{ + // Get task token for async completion + $taskToken = Activity::getInfo()->taskToken; + + // Store task token for later completion (e.g., in database) + $this->storeTaskToken($requestId, $taskToken); + + // Mark this activity as waiting for external completion + Activity::doNotCompleteOnReturn(); +} +``` + +Complete the activity from another process: + +```php +use Temporal\Client\WorkflowClient; + +$client = WorkflowClient::create(); +$taskToken = getStoredTaskToken($requestId); + +$completionClient = $client->newActivityCompletionClient(); +$completionClient->complete($taskToken, 'approved'); + +// Or fail it: +// $completionClient->completeExceptionally($taskToken, new \Exception('Rejected')); +``` + +**Note:** If the external system can reliably signal back with the result and doesn't need to heartbeat or receive cancellation, consider using **signals** instead. + +## Worker Tuning + +Configure worker performance settings. + +```php +use Temporal\Worker\WorkerOptions; + +$worker = $factory->newWorker( + taskQueue: 'my-queue', + options: WorkerOptions::new() + ->withMaxConcurrentWorkflowTaskPollers(5) + ->withMaxConcurrentActivityTaskPollers(5) + ->withMaxConcurrentWorkflowTaskExecutionSize(100) + ->withMaxConcurrentActivityExecutionSize(100) +); +``` + +PHP workers run as RoadRunner processes — the number of concurrent activities is also bounded by the number of RoadRunner worker processes configured in `.rr.yaml`. + +## RoadRunner Configuration + +PHP uses [RoadRunner](https://roadrunner.dev/) as the process supervisor. Configure it in `.rr.yaml`: + +```yaml +version: "3" + +temporal: + address: "localhost:7233" + namespace: "default" + activities: + num_workers: 10 # Number of PHP processes for activities + max_jobs: 100 # Restart worker after N jobs (prevents memory leaks) + memory_limit: 128MB # Restart worker if it exceeds this memory limit + +server: + command: "php worker.php" + relay: "pipes" +``` + +Key settings: +- `num_workers` — controls activity concurrency (set based on available CPU/memory) +- `max_jobs` — prevents memory leaks by recycling PHP processes after N executions +- `memory_limit` — safety net for runaway memory usage diff --git a/references/php/data-handling.md b/references/php/data-handling.md new file mode 100644 index 0000000..9d2b84a --- /dev/null +++ b/references/php/data-handling.md @@ -0,0 +1,232 @@ +# PHP SDK Data Handling + +## Overview + +The PHP SDK uses data converters to serialize/deserialize Workflow inputs, outputs, and Activity parameters. JSON is the default format. + +## Default Data Converter + +The default converter handles: +- `null` +- Scalars (`string`, `int`, `float`, `bool`) +- Arrays (JSON-serialized) +- Objects (JSON-serialized via public properties) + +**PHP-specific:** Workflow methods are generators. To specify the return type of a Workflow method, use the `#[ReturnType]` attribute on the interface method: + +```php +use Temporal\Workflow\ReturnType; + +#[WorkflowInterface] +interface OrderWorkflowInterface +{ + #[WorkflowMethod] + #[ReturnType(OrderResult::class)] + public function run(OrderInput $input): \Generator; +} +``` + +Without `#[ReturnType]`, the SDK cannot deserialize the result into the correct class. + +## Custom Data Converter + +Implement a custom `PayloadConverter` to handle types the default converter does not support: + +```php +use Temporal\DataConverter\PayloadConverter; +use Temporal\Api\Common\V1\Payload; + +class MyCustomConverter implements PayloadConverter +{ + public function getEncodingType(): string + { + return 'json/my-custom'; + } + + public function toPayload($value): ?Payload + { + if (!$value instanceof MyCustomType) { + return null; // Return null to let other converters handle it + } + + $payload = new Payload(); + $payload->setMetadata(['encoding' => $this->getEncodingType()]); + $payload->setData(json_encode($value->toArray())); + return $payload; + } + + public function fromPayload(Payload $payload, \ReflectionType $type) + { + return MyCustomType::fromArray(json_decode($payload->getData(), true)); + } +} +``` + +Register the custom converter when creating the `WorkflowClient`: + +```php +use Temporal\DataConverter\DataConverter; +use Temporal\DataConverter\JsonPayloadConverter; +use Temporal\DataConverter\NullPayloadConverter; + +$dataConverter = new DataConverter( + new NullPayloadConverter(), + new JsonPayloadConverter(), + new MyCustomConverter(), +); + +$client = WorkflowClient::create( + ServiceClient::create('localhost:7233'), + dataConverter: $dataConverter +); +``` + +## Payload Encryption + +Encrypt sensitive Workflow data using a custom `PayloadCodec`: + +```php +use Temporal\DataConverter\PayloadCodecInterface; +use Temporal\Api\Common\V1\Payload; + +class EncryptionCodec implements PayloadCodecInterface +{ + public function __construct(private string $key) {} + + public function encode(array $payloads): array + { + return array_map(function (Payload $payload) { + $encrypted = $this->encrypt($payload->serializeToString()); + $result = new Payload(); + $result->setMetadata(['encoding' => 'binary/encrypted']); + $result->setData($encrypted); + return $result; + }, $payloads); + } + + public function decode(array $payloads): array + { + return array_map(function (Payload $payload) { + if (($payload->getMetadata()['encoding'] ?? null) !== 'binary/encrypted') { + return $payload; + } + $decrypted = $this->decrypt($payload->getData()); + $result = new Payload(); + $result->mergeFromString($decrypted); + return $result; + }, $payloads); + } + + private function encrypt(string $data): string { /* ... */ } + private function decrypt(string $data): string { /* ... */ } +} +``` + +Apply the codec via `DataConverter` on the client: + +```php +$dataConverter = DataConverter::createDefault()->withCodec(new EncryptionCodec($encryptionKey)); + +$client = WorkflowClient::create( + ServiceClient::create('localhost:7233'), + dataConverter: $dataConverter +); +``` + +## Search Attributes + +Custom searchable fields for Workflow visibility. + +Define Search Attribute keys and set them at Workflow start: + +```php +use Temporal\Common\SearchAttributeKey; +use Temporal\Common\TypedSearchAttributes; + +$orderIdKey = SearchAttributeKey::forKeyword('OrderId'); +$orderStatusKey = SearchAttributeKey::forKeyword('OrderStatus'); + +$workflow = $client->newWorkflowStub( + OrderWorkflowInterface::class, + WorkflowOptions::new() + ->withTaskQueue('orders') + ->withTypedSearchAttributes( + TypedSearchAttributes::empty() + ->withValue($orderIdKey, $order->id) + ->withValue($orderStatusKey, 'pending') + ) +); +``` + +Upsert Search Attributes during Workflow execution: + +```php +use Temporal\Workflow; +use Temporal\Common\SearchAttributeKey; + +class OrderWorkflow implements OrderWorkflowInterface +{ + public function run(array $order): \Generator + { + // ... process order ... + + Workflow::upsertTypedSearchAttributes( + SearchAttributeKey::forKeyword('OrderStatus')->withValue('completed') + ); + + return 'done'; + } +} +``` + +### Querying Workflows by Search Attributes + +```php +$executions = $client->listWorkflowExecutions( + 'OrderStatus = "processing" OR OrderStatus = "pending"' +); + +foreach ($executions as $execution) { + echo "Workflow {$execution->getExecution()->getWorkflowId()} is still processing\n"; +} +``` + +## Workflow Memo + +Store arbitrary metadata with Workflows (not searchable). + +```php +// Set memo at Workflow start +$workflow = $client->newWorkflowStub( + OrderWorkflowInterface::class, + WorkflowOptions::new() + ->withTaskQueue('orders') + ->withMemo([ + 'customer_name' => $order->customerName, + 'notes' => 'Priority customer', + ]) +); +``` + +Upsert memo during Workflow execution: + +```php +class OrderWorkflow implements OrderWorkflowInterface +{ + public function run(array $order): \Generator + { + // ... process order ... + + Workflow::upsertMemo(['status' => 'fraud-checked']); + + return yield $this->activity->processPayment($order); + } +} +``` + +## Best Practices + +1. Use `#[ReturnType]` on Workflow interface methods to enable correct deserialization +2. Keep payloads small — see `references/core/gotchas.md` for limits +3. Encrypt sensitive data with a `PayloadCodec` +4. Use typed Search Attributes for business-level visibility and querying diff --git a/references/php/determinism-protection.md b/references/php/determinism-protection.md new file mode 100644 index 0000000..3255291 --- /dev/null +++ b/references/php/determinism-protection.md @@ -0,0 +1,43 @@ +# PHP Determinism Protection + +## Overview + +The PHP SDK does NOT have a sandbox. Unlike Python (exec-based sandbox) and TypeScript (V8 isolates), PHP relies entirely on runtime command-ordering checks and developer discipline. + +The `WorkflowPanicPolicy` enum controls what happens when non-determinism is detected at runtime: + +```php +use Temporal\Worker\WorkerOptions; +use Temporal\Worker\WorkflowPanicPolicy; + +// In worker setup +$worker = $factory->newWorker('task-queue', WorkerOptions::new() + ->withWorkflowPanicPolicy(WorkflowPanicPolicy::FailWorkflow) +); +``` + +## Forbidden Operations + +These operations must NOT be used in workflow code: + +- No I/O: `fopen()`, `file_get_contents()`, `curl_*`, PDO, etc. +- No `sleep()` — use `yield Workflow::timer()` +- No `time()`, `date()`, `microtime()` — use `Workflow::now()` +- No `rand()`, `random_int()`, `uniqid()` — use `yield Workflow::sideEffect()` +- No blocking SPL functions +- No mutable global variables + +## Common Issues + +RoadRunner-specific issues to watch for: + +- **Worker memory leaks:** PHP workers are long-running processes. Configure `max_jobs` in `.rr.yaml` to restart workers periodically. +- **Shared state between workflow executions:** Class-level static variables persist across executions in the same worker process. Avoid mutable statics in workflow code. +- **Long-running PHP processes:** Unlike traditional PHP request/response, RoadRunner workers persist — ensure resources are released properly. + +## Best Practices + +1. Keep workflow code pure — orchestration only, no side effects +2. Use activities for all I/O and external calls +3. Configure `WorkflowPanicPolicy::FailWorkflow` for development to surface non-determinism immediately +4. Use `WorkflowPanicPolicy::BlockWorkflow` (default) for production to allow investigation without data loss diff --git a/references/php/determinism.md b/references/php/determinism.md new file mode 100644 index 0000000..af2109c --- /dev/null +++ b/references/php/determinism.md @@ -0,0 +1,48 @@ +# PHP SDK Determinism + +## Overview + +The PHP SDK does NOT have a sandbox like Python or TypeScript. There is no automatic enforcement of determinism — the developer must be disciplined. The SDK provides runtime command-ordering checks only. + +## Why Determinism Matters: History Replay + +Temporal provides durable execution through **History Replay**. When a Worker needs to restore workflow state (after a crash, cache eviction, or to continue after a long timer), it re-executes the workflow code from the beginning, which requires the workflow code to be **deterministic**. + +See `references/core/determinism.md` for the full explanation. + +## SDK Protection / Runtime Checking + +The PHP SDK performs runtime checks that detect adding, removing, or reordering calls to: + +- `ExecuteActivity()` +- `ExecuteChildWorkflow()` +- `NewTimer()` +- `RequestCancelWorkflow()` +- `SideEffect()` +- `SignalExternalWorkflow()` +- `Sleep()` + +**This is NOT a thorough check** — it does not verify arguments or timer durations. Non-determinism that doesn't reorder commands will go undetected. Use replay testing to catch subtler issues. + +## Forbidden Operations + +These must NOT be used in workflow code: + +- No direct I/O: `fopen()`, `file_get_contents()`, `curl_*`, PDO, etc. +- No `sleep()` — use `yield Workflow::timer(new \DateInterval('PT10S'))` +- No `time()`, `date()`, `microtime()` — use `Workflow::now()` +- No `rand()`, `random_int()`, `uniqid()` — use `yield Workflow::sideEffect()` +- No blocking SPL functions +- No mutable global state + +## Testing Replay Compatibility + +Use the `WorkflowReplayer` class to verify your code changes are compatible with existing histories. See the Workflow Replay Testing section of `references/php/testing.md`. + +## Best Practices + +1. Use `Workflow::now()` for all time and date operations +2. Use `yield Workflow::sideEffect()` for any non-deterministic values +3. Delegate all I/O to activities +4. Test with `WorkflowReplayer` to catch non-determinism +5. Use `Workflow::getLogger()` instead of `error_log()` for replay-safe logging diff --git a/references/php/error-handling.md b/references/php/error-handling.md new file mode 100644 index 0000000..895f551 --- /dev/null +++ b/references/php/error-handling.md @@ -0,0 +1,131 @@ +# PHP SDK Error Handling + +## Overview + +The PHP SDK uses `ApplicationFailure` for application-specific errors and provides retry policy configuration via `RetryOptions`. Generally, the following information about errors and retryability applies across activities, child workflows, and Nexus operations. + +## Application Errors/Failures + +```php +use Temporal\Exception\Failure\ApplicationFailure; + +#[ActivityMethod] +public function validateOrder(Order $order): void +{ + if (!$order->isValid()) { + throw new ApplicationFailure( + message: 'Invalid order', + type: 'ValidationError', + nonRetryable: false, + ); + } +} +``` + +`ApplicationFailure` constructor: `new ApplicationFailure(string $message, string $type, bool $nonRetryable, array $details)`. + +## Non-Retryable Errors + +```php +use Temporal\Exception\Failure\ApplicationFailure; + +#[ActivityMethod] +public function chargeCard(ChargeCardInput $input): string +{ + if (!$this->isValidCard($input->cardNumber)) { + throw new ApplicationFailure( + message: 'Permanent failure - invalid credit card', + type: 'PaymentError', + nonRetryable: true, // Will not retry activity + ); + } + return $this->processPayment($input->cardNumber, $input->amount); +} +``` + +## Handling Activity Errors in Workflows + +```php +use Temporal\Exception\Failure\ApplicationFailure; +use Temporal\Exception\Failure\ActivityFailure; + +#[WorkflowMethod] +public function run(): string +{ + try { + return yield $this->myActivity->doSomething('input'); + } catch (ActivityFailure $e) { + // $e->getPrevious() contains the original ApplicationFailure + $cause = $e->getPrevious(); + throw new ApplicationFailure( + message: 'Workflow failed due to activity error', + type: 'WorkflowError', + nonRetryable: false, + ); + } +} +``` + +Activities throw exceptions; workflows catch `ActivityFailure` (which wraps the original exception). + +## Retry Configuration + +```php +use Temporal\Activity\ActivityOptions; +use Temporal\Common\RetryOptions; +use Carbon\CarbonInterval; + +$options = ActivityOptions::new() + ->withRetryOptions( + RetryOptions::new() + ->withInitialInterval(CarbonInterval::seconds(1)) + ->withMaximumInterval(CarbonInterval::minutes(1)) + ->withMaximumAttempts(5) + ->withNonRetryableExceptions(['ValidationError', 'PaymentError']) + ); + +$result = yield $this->myActivityStub->withOptions($options)->doSomething('input'); +``` + +Only set options such as `withMaximumInterval`, `withMaximumAttempts` etc. if you have a domain-specific reason to. If not, prefer to leave them at their defaults. + +## Timeout Configuration + +```php +use Temporal\Activity\ActivityOptions; +use Carbon\CarbonInterval; + +$options = ActivityOptions::new() + ->withScheduleToCloseTimeout(CarbonInterval::minutes(30)) // Including retries + ->withStartToCloseTimeout(CarbonInterval::minutes(5)) // Single attempt + ->withHeartbeatTimeout(CarbonInterval::minutes(2)); // Between heartbeats + +$result = yield $this->myActivityStub->withOptions($options)->doSomething('input'); +``` + +## Workflow Failure + +```php +use Temporal\Exception\Failure\ApplicationFailure; + +#[WorkflowMethod] +public function run(): string +{ + if ($someCondition) { + throw new ApplicationFailure( + message: 'Cannot process order', + type: 'BusinessError', + nonRetryable: false, + ); + } + return 'success'; +} +``` + +## Best Practices + +1. Use specific error types (the `type` parameter) for different failure modes +2. Mark permanent failures as non-retryable with `nonRetryable: true` +3. Configure appropriate retry policies for activities +4. Catch `ActivityFailure` in workflows — the original exception is in `$e->getPrevious()` +5. Design activity code to be idempotent for safe retries (see more at `references/core/patterns.md`) diff --git a/references/php/gotchas.md b/references/php/gotchas.md new file mode 100644 index 0000000..fd9d722 --- /dev/null +++ b/references/php/gotchas.md @@ -0,0 +1,156 @@ +# PHP Gotchas + +PHP-specific mistakes and anti-patterns. See also `references/core/gotchas.md` for language-agnostic concepts. + +## Wrong Retry Classification + +**Example:** Transient network errors should be retried. Authentication errors should not be. +See `references/php/error-handling.md` to understand how to classify errors. + +## Cancellation + +### Not Handling Workflow Cancellation + +```php +// BAD - Cleanup doesn't run on cancellation +#[WorkflowMethod] +public function run(): void +{ + yield $this->myActivity->acquireResource(); + yield $this->myActivity->doWork(); + yield $this->myActivity->releaseResource(); // Never runs if cancelled! +} + +// GOOD - Use try/finally for cleanup +#[WorkflowMethod] +public function run(): void +{ + yield $this->myActivity->acquireResource(); + try { + yield $this->myActivity->doWork(); + } finally { + // Runs even on cancellation + yield $this->myActivity->releaseResource(); + } +} +``` + +When a workflow is cancelled from the client, a `Temporal\Exception\Client\WorkflowFailedException` is thrown on the caller side. Inside the workflow, cancellation arrives as a `CanceledFailure` on the yielded promise. + +### Not Handling Activity Cancellation + +Activities detect cancellation through heartbeat. Without heartbeating, an activity runs to completion even when cancelled. + +```php +// BAD - Activity ignores cancellation +#[ActivityMethod] +public function longRunningTask(): void +{ + foreach ($this->items as $item) { + $this->process($item); // Runs to completion even if cancelled + } +} + +// GOOD - Heartbeat and detect cancellation +#[ActivityMethod] +public function longRunningTask(): void +{ + foreach ($this->items as $i => $item) { + Activity::heartbeat(['progress' => $i]); // Throws on cancellation + $this->process($item); + } +} +``` + +`Activity::heartbeat()` throws `Temporal\Exception\Failure\CanceledFailure` when the activity has been cancelled. Let it propagate or catch it for cleanup. + +## Heartbeating + +### Forgetting to Heartbeat Long Activities + +```php +// BAD - No heartbeat, can't detect stuck activities +#[ActivityMethod] +public function processLargeFile(string $path): void +{ + foreach ($this->readChunks($path) as $chunk) { + $this->process($chunk); // Takes hours, no heartbeat + } +} + +// GOOD - Regular heartbeats with progress +#[ActivityMethod] +public function processLargeFile(string $path): void +{ + foreach ($this->readChunks($path) as $i => $chunk) { + Activity::heartbeat(['chunk' => $i]); + $this->process($chunk); + } +} +``` + +### Heartbeat Timeout Too Short + +```php +// BAD - Heartbeat timeout shorter than processing time +$options = ActivityOptions::new() + ->withStartToCloseTimeout(CarbonInterval::minutes(30)) + ->withHeartbeatTimeout(CarbonInterval::seconds(10)); // Too short! + +// GOOD - Heartbeat timeout allows for processing variance +$options = ActivityOptions::new() + ->withStartToCloseTimeout(CarbonInterval::minutes(30)) + ->withHeartbeatTimeout(CarbonInterval::minutes(2)); +``` + +Set heartbeat timeout as high as acceptable for your use case — each heartbeat counts as an action. + +## Testing + +### Not Testing Failures + +It is important to make sure workflows work as expected under failure paths in addition to happy paths. Please see `references/php/testing.md` for more info. + +### Not Testing Replay + +Replay tests help you test that you do not have hidden sources of non-determinism bugs in your workflow code, and should be considered in addition to standard testing. Please see `references/php/testing.md` for more info. + +## Timers and Sleep + +### Using sleep() in Workflows + +```php +// BAD: sleep() is not deterministic during replay +#[WorkflowMethod] +public function run(): void +{ + sleep(60); // Non-deterministic! Uses wall clock, not workflow timer +} + +// GOOD: Use Workflow::timer() for deterministic timers +#[WorkflowMethod] +public function run(): \Generator +{ + yield Workflow::timer(60); + // Or with CarbonInterval: + yield Workflow::timer(CarbonInterval::seconds(60)); +} +``` + +**Why this matters:** `sleep()` uses the system clock, which differs between original execution and replay. `Workflow::timer()` creates a durable timer in the event history, ensuring consistent behavior during replay. + +### Other PHP-Specific Determinism Mistakes + +```php +// BAD: no yield on activity call — fire-and-forget, result is lost +$this->myActivity->doSomething(); + +// GOOD: always yield activity calls +$result = yield $this->myActivity->doSomething(); + +// BAD: time() uses wall clock — non-deterministic +$now = time(); + +// GOOD: use Workflow::now() for deterministic time +$now = Workflow::now(); // Returns \DateTimeImmutable +``` diff --git a/references/php/observability.md b/references/php/observability.md new file mode 100644 index 0000000..1aebc03 --- /dev/null +++ b/references/php/observability.md @@ -0,0 +1,119 @@ +# PHP SDK Observability + +## Overview + +The PHP SDK provides observability through PSR-3 logging (with replay-aware Workflow logger), and visibility via Search Attributes. + +## Logging / Replay-Aware Logging + +### Workflow Logging + +Use `Workflow::getLogger()` for replay-safe logging inside Workflows: + +```php +use Temporal\Workflow; + +class OrderWorkflow implements OrderWorkflowInterface +{ + public function run(array $order): \Generator + { + Workflow::getLogger()->info('Workflow started', ['orderId' => $order['id']]); + + $result = yield $this->activity->processPayment($order); + + Workflow::getLogger()->info('Payment processed', ['result' => $result]); + + return $result; + } +} +``` + +The Workflow logger automatically suppresses duplicate log messages during replay by default. + +### Activity Logging + +Activities are not replayed, so use any standard PSR-3 logger (injected via constructor or DI): + +```php +use Psr\Log\LoggerInterface; + +class OrderActivity implements OrderActivityInterface +{ + public function __construct(private LoggerInterface $logger) {} + + public function processPayment(array $order): string + { + $this->logger->info('Processing payment', ['orderId' => $order['id']]); + + // Perform work... + + $this->logger->info('Payment complete'); + return 'completed'; + } +} +``` + +### Enabling Logging During Replay + +By default, `Workflow::getLogger()` suppresses logs during replay. To enable logging during replay (useful for debugging): + +```php +use Temporal\Worker\WorkerOptions; + +$worker = $factory->newWorker( + taskQueue: 'orders', + options: WorkerOptions::new()->withEnableLoggingInReplay(true) +); +``` + +## Customizing the Logger + +Pass a custom PSR-3 logger when creating the Worker: + +```php +use Monolog\Logger; +use Monolog\Handler\StreamHandler; +use Temporal\WorkerFactory; + +$logger = new Logger('temporal'); +$logger->pushHandler(new StreamHandler('php://stdout')); + +$factory = WorkerFactory::create(); + +$worker = $factory->newWorker( + taskQueue: 'my-task-queue', + logger: $logger +); +``` + +Any PSR-3 compatible logger (Monolog, etc.) can be used. + +## Search Attributes (Visibility) + +Use Search Attributes to make Workflow executions queryable by business fields. See `references/php/data-handling.md` for how to set and upsert Search Attributes. + +Query Workflow executions using Search Attributes: + +```php +$executions = $client->listWorkflowExecutions( + 'WorkflowType = "OrderWorkflow" AND ExecutionStatus = "Running" AND OrderStatus = "pending"' +); + +foreach ($executions as $execution) { + echo "Pending order: {$execution->getExecution()->getWorkflowId()}\n"; +} +``` + +Or using the Temporal CLI: + +```bash +temporal workflow list --query \ + 'WorkflowType = "OrderWorkflow" AND ExecutionStatus = "Running" AND OrderStatus = "pending"' +``` + +## Best Practices + +1. Use `Workflow::getLogger()` inside Workflow code for replay-safe logging +2. Do not use `echo` or `print()` in Workflows — output appears on every replay +3. Use standard PSR-3 loggers in Activities (no replay concern) +4. Use Search Attributes for business-level visibility and querying across Workflow executions diff --git a/references/php/patterns.md b/references/php/patterns.md new file mode 100644 index 0000000..236d978 --- /dev/null +++ b/references/php/patterns.md @@ -0,0 +1,468 @@ +# PHP SDK Patterns + +## Signals + +```php +use Temporal\Workflow; +use Temporal\Workflow\WorkflowInterface; +use Temporal\Workflow\WorkflowMethod; +use Temporal\Workflow\SignalMethod; + +#[WorkflowInterface] +class OrderWorkflow +{ + private bool $approved = false; + private array $items = []; + + #[SignalMethod] + public function approve(): void + { + $this->approved = true; + } + + #[SignalMethod] + public function addItem(string $item): void + { + $this->items[] = $item; + } + + #[WorkflowMethod] + public function run(): \Generator + { + // Wait for approval + yield Workflow::await(fn() => $this->approved); + return sprintf('Processed %d items', count($this->items)); + } +} +``` + +### Dynamic Signal Handlers + +For handling signals with names not known at compile time. Use cases for this pattern are rare — most workflows should use statically defined signal handlers. + +```php +#[WorkflowInterface] +class DynamicSignalWorkflow +{ + private array $signals = []; + + public function __construct() + { + Workflow::registerDynamicSignal(function (string $name, array $args): void { + $this->signals[$name][] = $args[0] ?? null; + }); + } + + #[WorkflowMethod] + public function run(): \Generator + { + yield Workflow::await(fn() => isset($this->signals['done'])); + return $this->signals; + } +} +``` + +## Queries + +**Important:** Queries must NOT modify workflow state or have side effects. + +```php +use Temporal\Workflow\QueryMethod; + +#[WorkflowInterface] +class StatusWorkflow +{ + private string $status = 'pending'; + private int $progress = 0; + + #[QueryMethod] + public function getStatus(): string + { + return $this->status; + } + + #[QueryMethod] + public function getProgress(): int + { + return $this->progress; + } + + #[WorkflowMethod] + public function run(): \Generator + { + $this->status = 'running'; + for ($i = 0; $i < 100; $i++) { + $this->progress = $i; + yield Workflow::newActivityStub( + MyActivities::class, + ActivityOptions::new()->withStartToCloseTimeout(CarbonInterval::minutes(1)) + )->processItem($i); + } + $this->status = 'completed'; + return 'done'; + } +} +``` + +### Dynamic Query Handlers + +For handling queries with names not known at compile time. Use cases for this pattern are rare — most workflows should use statically defined query handlers. + +```php +#[WorkflowInterface] +class DynamicQueryWorkflow +{ + private array $state = ['status' => 'running', 'progress' => 0]; + + public function __construct() + { + Workflow::registerDynamicQuery(function (string $name, array $args): mixed { + return $this->state[$name] ?? null; + }); + } + + #[WorkflowMethod] + public function run(): \Generator + { + // ... workflow logic + yield Workflow::timer(CarbonInterval::seconds(1)); + } +} +``` + +## Updates + +```php +use Temporal\Workflow\UpdateMethod; +use Temporal\Workflow\UpdateValidatorMethod; + +#[WorkflowInterface] +class OrderWorkflow +{ + private array $items = []; + + #[UpdateMethod] + public function addItem(string $item): int + { + $this->items[] = $item; + return count($this->items); // Returns new count to caller + } + + #[UpdateValidatorMethod(forUpdate: 'addItem')] + public function validateAddItem(string $item): void + { + if (empty($item)) { + throw new \InvalidArgumentException('Item cannot be empty'); + } + if (count($this->items) >= 100) { + throw new \OverflowException('Order is full'); + } + } + + #[WorkflowMethod] + public function run(): \Generator + { + yield Workflow::await(fn() => count($this->items) > 0); + return sprintf('Order with %d items', count($this->items)); + } +} +``` + +## Child Workflows + +```php +#[WorkflowInterface] +class ParentWorkflow +{ + #[WorkflowMethod] + public function run(array $orders): \Generator + { + $results = []; + foreach ($orders as $order) { + $result = yield Workflow::newChildWorkflowStub( + ProcessOrderWorkflow::class, + ChildWorkflowOptions::new() + ->withWorkflowId('order-' . $order->id) + ->withParentClosePolicy(ParentClosePolicy::POLICY_ABANDON) + )->run($order); + $results[] = $result; + } + return $results; + } +} +``` + +Alternatively, use `Workflow::executeChildWorkflow()` for a one-shot call: + +```php +$result = yield Workflow::executeChildWorkflow( + 'ProcessOrderWorkflow', + [$order], + ChildWorkflowOptions::new()->withWorkflowId('order-' . $order->id) +); +``` + +## Handles to External Workflows + +```php +#[WorkflowInterface] +class CoordinatorWorkflow +{ + #[WorkflowMethod] + public function run(string $targetWorkflowId): \Generator + { + // Get stub for external workflow + $handle = Workflow::newExternalWorkflowStub( + TargetWorkflow::class, + $targetWorkflowId + ); + + // Signal the external workflow + yield $handle->dataReady($dataPayload); + + // Or cancel it + yield $handle->cancel(); + } +} +``` + +## Parallel Execution + +```php +use Temporal\Workflow; + +#[WorkflowInterface] +class ParallelWorkflow +{ + #[WorkflowMethod] + public function run(array $items): \Generator + { + $activities = Workflow::newActivityStub( + MyActivities::class, + ActivityOptions::new()->withStartToCloseTimeout(CarbonInterval::minutes(5)) + ); + + // Launch all activities in parallel using Workflow::async() + $promises = []; + foreach ($items as $item) { + $promises[] = Workflow::async(fn() => yield $activities->processItem($item)); + } + + // Wait for all to complete + $results = []; + foreach ($promises as $promise) { + $results[] = yield $promise; + } + return $results; + } +} +``` + +## Continue-as-New + +```php +use Temporal\Workflow; + +#[WorkflowInterface] +class LongRunningWorkflow +{ + #[WorkflowMethod] + public function run(WorkflowState $state): \Generator + { + while (true) { + $state = yield $this->processNextBatch($state); + + if ($state->isComplete) { + return 'done'; + } + + // Continue with fresh history before hitting limits + if (Workflow::getInfo()->shouldContinueAsNew) { + return Workflow::continueAsNew($state); + } + } + } +} +``` + +## Saga Pattern (Compensations) + +**Important:** Compensation activities should be idempotent — they may be retried (as with ALL activities). + +```php +#[WorkflowInterface] +class OrderSagaWorkflow +{ + #[WorkflowMethod] + public function run(Order $order): \Generator + { + $compensations = []; + $activities = Workflow::newActivityStub( + OrderActivities::class, + ActivityOptions::new()->withStartToCloseTimeout(CarbonInterval::minutes(5)) + ); + + try { + // Note: save the compensation BEFORE running the activity, + // because the activity could succeed but fail to report (timeout, crash, etc.). + // The compensation must handle both reserved and unreserved states. + $compensations[] = fn() => yield $activities->releaseInventoryIfReserved($order); + yield $activities->reserveInventory($order); + + $compensations[] = fn() => yield $activities->refundPaymentIfCharged($order); + yield $activities->chargePayment($order); + + yield $activities->shipOrder($order); + + return 'Order completed'; + } catch (\Throwable $e) { + Workflow::getLogger()->error('Order failed, running compensations', ['error' => $e->getMessage()]); + foreach (array_reverse($compensations) as $compensate) { + try { + yield $compensate(); + } catch (\Throwable $compErr) { + Workflow::getLogger()->error('Compensation failed', ['error' => $compErr->getMessage()]); + } + } + throw $e; + } + } +} +``` + +## Wait Condition with Timeout + +```php +#[WorkflowInterface] +class ApprovalWorkflow +{ + private bool $approved = false; + + #[SignalMethod] + public function approve(): void + { + $this->approved = true; + } + + #[WorkflowMethod] + public function run(): \Generator + { + // Wait for approval with 24-hour timeout + $approved = yield Workflow::awaitWithTimeout( + CarbonInterval::hours(24), + fn() => $this->approved + ); + + if ($approved) { + return 'approved'; + } + return 'auto-rejected due to timeout'; + } +} +``` + +## Waiting for All Handlers to Finish + +Signal and update handlers should generally be non-async (avoid running activities from them). Otherwise, the workflow may complete before handlers finish their execution. However, making handlers non-async sometimes requires workarounds that add complexity. + +When async handlers are necessary, use `Workflow::await(Workflow::allHandlersFinished())` at the end of your workflow (or before continue-as-new) to prevent completion until all pending handlers complete. + +```php +#[WorkflowInterface] +class HandlerAwareWorkflow +{ + #[WorkflowMethod] + public function run(): \Generator + { + // ... main workflow logic ... + + // Before exiting, wait for all handlers to finish + yield Workflow::await(Workflow::allHandlersFinished()); + return 'done'; + } +} +``` + +## Activity Heartbeating + +### WHY: +- **Support activity cancellation** — Cancellations are delivered via heartbeat; activities that don't heartbeat won't know they've been cancelled +- **Resume progress after worker failure** — Heartbeat details persist across retries + +### WHEN: +- **Cancellable activities** — Any activity that should respond to cancellation +- **Long-running activities** — Track progress for resumability +- **Checkpointing** — Save progress periodically + +```php +use Temporal\Activity; +use Temporal\Activity\ActivityInterface; +use Temporal\Activity\ActivityMethod; +use Temporal\Exception\Failure\CanceledFailure; + +#[ActivityInterface] +class FileProcessingActivities +{ + #[ActivityMethod] + public function processLargeFile(string $filePath): string + { + // Get heartbeat details from previous attempt (if any) + $heartbeatDetails = Activity::getHeartbeatDetails(); + $startLine = $heartbeatDetails[0] ?? 0; + + $lines = file($filePath); + + try { + for ($i = $startLine; $i < count($lines); $i++) { + $this->processLine($lines[$i]); + + // Heartbeat with progress + // If cancelled, heartbeat() throws CanceledFailure + Activity::heartbeat($i + 1); + } + return 'completed'; + } catch (CanceledFailure $e) { + // Perform cleanup on cancellation + $this->cleanup(); + throw $e; + } + } +} +``` + +## Timers + +```php +#[WorkflowInterface] +class TimerWorkflow +{ + #[WorkflowMethod] + public function run(): \Generator + { + yield Workflow::timer(CarbonInterval::hours(1)); + return 'Timer fired'; + } +} +``` + +## Local Activities + +**Purpose**: Reduce latency for short, lightweight operations by skipping the task queue. ONLY use these when necessary for performance. Do NOT use these by default, as they are not durable and distributed. + +```php +#[WorkflowInterface] +class LocalActivityWorkflow +{ + #[WorkflowMethod] + public function run(): \Generator + { + $activities = Workflow::newActivityStub( + LookupActivities::class, + LocalActivityOptions::new()->withStartToCloseTimeout(CarbonInterval::seconds(5)) + ); + + $result = yield $activities->quickLookup('key'); + return $result; + } +} +``` diff --git a/references/php/php.md b/references/php/php.md new file mode 100644 index 0000000..33909ec --- /dev/null +++ b/references/php/php.md @@ -0,0 +1,243 @@ +# Temporal PHP SDK Reference + +## Overview + +The Temporal PHP SDK (`temporal/sdk`) uses RoadRunner as the application server to run workflows and activities. PHP 8.1+ required. Workflows and activities are defined as classes using PHP attributes (`#[WorkflowInterface]`, `#[ActivityInterface]`, etc.). Async operations use generators with `yield` instead of `await`. There is no sandbox — the SDK relies on runtime determinism checks to detect non-deterministic code. + +## Quick Demo of Temporal + +**Add Dependency on Temporal:** Install the SDK via Composer: + +```bash +composer require temporal/sdk +``` + +**src/Activity/GreetingActivityInterface.php** - Activity interface: +```php +activity = Workflow::newActivityStub( + GreetingActivityInterface::class, + ActivityOptions::new()->withStartToCloseTimeout(30) + ); + } + + public function greet(string $name): \Generator + { + return yield $this->activity->greet($name); + } +} +``` + +**worker.php** - Worker setup (runs via RoadRunner, processes tasks indefinitely): +```php +newWorker('my-task-queue'); + +// Register workflow and activity implementations +$worker->registerWorkflowTypes(GreetingWorkflow::class); +$worker->registerActivity(GreetingActivity::class); + +// Start processing tasks (blocks until stopped) +$factory->run(); +``` + +**Start the dev server:** Start `temporal server start-dev` in the background. + +**Start the worker:** Start `php worker.php` in the background (RoadRunner must be available; alternatively use `./rr serve` with an `.rr.yaml` config). + +**starter.php** - Start a workflow execution: +```php +newWorkflowStub( + GreetingWorkflowInterface::class, + WorkflowOptions::new()->withTaskQueue('my-task-queue') +); + +$result = $workflow->greet('World'); + +echo "Result: {$result}" . PHP_EOL; +``` + +**Run the workflow:** Run `php starter.php`. Should output: `Result: Hello, World!`. + + +## Key Concepts + +### Workflow Definition +- Use `#[WorkflowInterface]` attribute on the interface +- Use `#[WorkflowMethod]` on the entry point method +- Workflow method must return `\Generator` (use `yield` for async calls) +- Use `#[SignalMethod]`, `#[QueryMethod]`, `#[UpdateMethod]` attributes for handlers +- Implementation class does not need any attributes — attributes go on the interface + +### Activity Definition +- Use `#[ActivityInterface]` attribute on the interface +- Use `#[ActivityMethod]` on each activity method +- Implementation class contains the actual logic — no Temporal attributes needed +- Activities can perform I/O, call external services, use `sleep()`, etc. + +### Worker Setup +- Create `WorkerFactory::create()` — connects through RoadRunner +- Call `$factory->newWorker('task-queue')` to bind to a task queue +- Register workflow types: `$worker->registerWorkflowTypes(MyWorkflow::class)` +- Register activities: `$worker->registerActivity(MyActivity::class)` (or pass an instance) +- Call `$factory->run()` to start processing (blocks) + +### Determinism + +**Workflow code must be deterministic!** The PHP SDK has no sandbox. All non-deterministic operations must use Temporal-provided APIs or be delegated to activities. Read `references/core/determinism.md` and `references/php/determinism.md` for details. + +## File Organization Best Practice + +**Keep Workflow definitions in separate files from Activity definitions.** Use interfaces to decouple workflows from activity implementations. + +``` +my_temporal_app/ +├── src/ +│ ├── Workflow/ +│ │ ├── GreetingWorkflowInterface.php # Workflow interface only +│ │ └── GreetingWorkflow.php # Workflow implementation +│ └── Activity/ +│ ├── GreetingActivityInterface.php # Activity interface only +│ └── GreetingActivity.php # Activity implementation +├── worker.php # Worker setup, registers both +└── starter.php # Client code to start workflows +``` + +Workflows reference activities only through their interfaces. This keeps the workflow file free of activity implementation details and avoids unnecessary coupling. + +## Determinism Rules + +PHP has **no sandbox**. Non-deterministic code in a workflow will cause history replay failures. Do not use: + +| Forbidden | Use Instead | +|-----------|-------------| +| `sleep($seconds)` | `yield Workflow::timer($seconds)` | +| `time()` / `microtime()` / `new \DateTime()` | `Workflow::now()` (returns `\DateTimeImmutable`) | +| `rand()` / `mt_rand()` / `random_int()` | `yield Workflow::sideEffect(fn() => rand())` | +| Direct I/O (`file_get_contents`, `curl_exec`, DB queries) | Execute an activity | +| Blocking SPL functions that depend on external state | Execute an activity | +| `getenv()` / `$_ENV` reads (non-constant) | Pass via workflow input or use `sideEffect` | + +Always `yield` promises returned by activity stubs and `Workflow::*` async methods. Forgetting `yield` means the workflow continues without waiting for the result. + +## Common Pitfalls + +1. **Non-deterministic code in workflows** — Use activities for all I/O, randomness, and time-dependent logic +2. **Forgetting `yield` on promises** — `$this->activity->greet($name)` returns a promise; without `yield` the workflow gets the promise object, not the result +3. **Blocking operations in workflow code** — Never call `sleep()`, make HTTP requests, or query a database directly inside a workflow method +4. **Not heartbeating long-running activities** — Long activities must call `Activity::heartbeat()` periodically or Temporal will time them out +5. **Using `echo` or `print()` in workflows** — Use `Workflow::getLogger()->info(...)` instead for replay-safe logging +6. **Mixing workflow and activity classes in the same file** — Keep them separate for clarity and maintainability +7. **Registering the wrong class** — Register the implementation class (e.g., `GreetingWorkflow::class`), not the interface +8. **Missing `declare(strict_types=1)`** — Omitting strict types can cause subtle type coercion bugs in workflow data + +## Writing Tests + +See `references/php/testing.md` for info on writing tests. + +## Additional Resources + +### Reference Files +- **`references/php/patterns.md`** - Signals, queries, child workflows, saga pattern, etc. +- **`references/php/determinism.md`** - Forbidden operations, safe alternatives, runtime checks +- **`references/php/error-handling.md`** - ApplicationFailure, retry policies, non-retryable errors, idempotency +- **`references/php/observability.md`** - Logging, metrics, tracing, Search Attributes +- **`references/php/testing.md`** - Testing workflows and activities with the PHP SDK +- **`references/php/versioning.md`** - Patching API, workflow type versioning +- **`references/core/determinism.md`** - Core determinism concepts shared across all SDKs diff --git a/references/php/testing.md b/references/php/testing.md new file mode 100644 index 0000000..d806d8f --- /dev/null +++ b/references/php/testing.md @@ -0,0 +1,211 @@ +# PHP SDK Testing + +## Overview + +You test Temporal PHP Workflows using PHPUnit with the Temporal testing package. The PHP SDK provides `WorkerFactory` from `Temporal\Testing` and a RoadRunner test server for running workflows in an isolated environment. + +## Test Environment Setup + +Set up a `bootstrap.php` to initialize the test environment: + +```php +use Temporal\Testing\Environment; + +$environment = Environment::create(); +$environment->start(); + +register_shutdown_function(function () use ($environment): void { + $environment->stop(); +}); +``` + +Configure `phpunit.xml` to use the bootstrap: + +```xml + + + + tests + + + +``` + +A test case uses `WorkerFactory` from the Testing namespace and registers workflows and activities: + +```php +use PHPUnit\Framework\TestCase; +use Temporal\Testing\WorkerFactory; + +class MyWorkflowTest extends TestCase +{ + private WorkerFactory $factory; + + protected function setUp(): void + { + $this->factory = WorkerFactory::create(); + $worker = $this->factory->newWorker(); + $worker->registerWorkflowTypes(MyWorkflow::class); + $worker->registerActivity(MyActivity::class); + $this->factory->start(); + } + + protected function tearDown(): void + { + $this->factory->stop(); + } +} +``` + +## Activity Mocking + +Use `ActivityMocker` to mock activities without executing their real implementation: + +```php +use PHPUnit\Framework\TestCase; +use Temporal\Testing\ActivityMocker; +use Temporal\Testing\WorkerFactory; + +class MyWorkflowTest extends TestCase +{ + private WorkerFactory $factory; + private ActivityMocker $activityMocks; + + protected function setUp(): void + { + $this->factory = WorkerFactory::create(); + $worker = $this->factory->newWorker(); + $worker->registerWorkflowTypes(MyWorkflow::class); + $this->factory->start(); + + $this->activityMocks = new ActivityMocker(); + } + + protected function tearDown(): void + { + $this->activityMocks->clear(); + $this->factory->stop(); + } + + public function testWorkflowWithMock(): void + { + $this->activityMocks->expectCompletion( + MyActivity::class . '::doSomething', + 'mocked result' + ); + + $workflow = $this->factory->getClient()->newWorkflowStub(MyWorkflow::class); + $result = $workflow->run('input'); + + $this->assertEquals('expected output', $result); + } +} +``` + +`expectCompletion(string $name, mixed $result)` — mock a successful activity result. + +## Testing Signals and Queries + +Start a workflow asynchronously, send a signal via the client, then query state: + +```php +public function testSignalAndQuery(): void +{ + $workflow = $this->factory->getClient()->newWorkflowStub(MyWorkflow::class); + + // Start workflow asynchronously + $run = $this->factory->getClient()->startWorkflow($workflow, 'input'); + + // Send signal + $workflow->mySignal('signal data'); + + // Query state + $status = $workflow->getStatus(); + $this->assertEquals('expected', $status); + + // Wait for completion + $result = $run->getResult(); + $this->assertEquals('done', $result); +} +``` + +## Testing Failure Cases + +Mock activity failures with `expectFailure()`: + +```php +public function testActivityFailureHandling(): void +{ + $this->activityMocks->expectFailure( + MyActivity::class . '::doSomething', + new \RuntimeException('Simulated failure') + ); + + $workflow = $this->factory->getClient()->newWorkflowStub(MyWorkflow::class); + + $this->expectException(\Temporal\Exception\Failure\ApplicationFailure::class); + $workflow->run('input'); +} +``` + +## Replay Testing + +Use `WorkflowReplayer` to verify workflow determinism against recorded histories: + +```php +use Temporal\Testing\WorkflowReplayer; + +// Replay from server +$replayer = new WorkflowReplayer(); +$replayer->replayFromServer( + workflowType: MyWorkflow::class, + workflowId: 'workflow-id-to-replay', +); + +// Replay from JSON file +$replayer->replayFromJSON( + workflowType: MyWorkflow::class, + path: __DIR__ . '/history.json', +); + +// Replay from a WorkflowHistory object +$replayer->replayHistory( + workflowType: MyWorkflow::class, + history: $history, +); +``` + +## Activity Testing + +Test activities directly without a workflow: + +```php +use PHPUnit\Framework\TestCase; + +class MyActivityTest extends TestCase +{ + private MyActivity $activity; + + protected function setUp(): void + { + $this->activity = new MyActivity(); + } + + public function testActivity(): void + { + $result = $this->activity->doSomething('arg1'); + $this->assertEquals('expected', $result); + } +} +``` + +Activities are plain PHP classes — test them directly by instantiating and calling methods. + +## Best Practices + +1. Use the test environment (`Temporal\Testing\Environment`) for all workflow tests +2. Mock external dependencies using `ActivityMocker` rather than calling real services +3. Test replay compatibility when changing workflow code to catch determinism violations +4. Use unique workflow IDs per test to avoid conflicts +5. Call `$this->activityMocks->clear()` in `tearDown()` to reset mocks between tests +6. Test signal and query handlers explicitly with async workflow start diff --git a/references/php/versioning.md b/references/php/versioning.md new file mode 100644 index 0000000..d5e5d37 --- /dev/null +++ b/references/php/versioning.md @@ -0,0 +1,193 @@ +# PHP SDK Versioning + +For conceptual overview and guidance on choosing an approach, see `references/core/versioning.md`. + +## Patching API + +### The getVersion() Function + +PHP uses `Workflow::getVersion()` (not `patched()`) to check whether a Workflow should run new or old code: + +```php +use Temporal\Workflow; + +class OrderWorkflow implements OrderWorkflowInterface +{ + public function run(array $order): \Generator + { + $version = yield Workflow::getVersion('add-fraud-check', Workflow::DEFAULT_VERSION, 1); + + if ($version === 1) { + // New code path + yield $this->activity->checkFraud($order); + } + // else: DEFAULT_VERSION — old code path (for replay of pre-patch executions) + + return yield $this->activity->processPayment($order); + } +} +``` + +**How it works:** +- `getVersion(changeId, minSupported, maxSupported)` records a marker in the Workflow history +- For new executions: returns `maxSupported` (e.g., `1`) +- For replay of pre-patch history: returns `Workflow::DEFAULT_VERSION` (value: `-1`) +- `DEFAULT_VERSION` represents executions that predate the patch + +**PHP-specific:** `getVersion()` is a coroutine — always `yield` it. + +### Three-Step Patching Process + +Patching is a three-step process for safely deploying changes. + +**Warning:** Failing to follow this process will result in non-determinism errors for in-flight Workflows. + +**Step 1: Patch in New Code** + +Add the version check with both old and new code paths: + +```php +public function run(array $order): \Generator +{ + $version = yield Workflow::getVersion('add-fraud-check', Workflow::DEFAULT_VERSION, 1); + + if ($version === 1) { + // New: Run fraud check before payment + yield $this->activity->checkFraud($order); + } + // DEFAULT_VERSION: skip fraud check (original behavior) + + return yield $this->activity->processPayment($order); +} +``` + +**Step 2: Deprecate the Patch** + +Once all pre-patch Workflow Executions have completed, remove the old branch. Keep the `getVersion()` call with `minSupported = maxSupported = 1`: + +```php +public function run(array $order): \Generator +{ + // minSupported = 1: will throw on replay of pre-patch history (safe — those are all done) + yield Workflow::getVersion('add-fraud-check', 1, 1); + + // Only new code remains + yield $this->activity->checkFraud($order); + + return yield $this->activity->processPayment($order); +} +``` + +**Step 3: Remove the Version Call** + +After all Workflows that passed through Step 2 have completed, remove the `getVersion()` call entirely: + +```php +public function run(array $order): \Generator +{ + yield $this->activity->checkFraud($order); + + return yield $this->activity->processPayment($order); +} +``` + +### Query Filters for Finding Workflows by Version + +Use List Filters to find Workflows with specific patch versions: + +```bash +# Find running Workflows with a specific patch +temporal workflow list --query \ + 'WorkflowType = "OrderWorkflow" AND ExecutionStatus = "Running" AND TemporalChangeVersion = "add-fraud-check"' + +# Find running Workflows without any patch (pre-patch versions) +temporal workflow list --query \ + 'WorkflowType = "OrderWorkflow" AND ExecutionStatus = "Running" AND TemporalChangeVersion IS NULL' +``` + +## Workflow Type Versioning + +For incompatible changes, create a new Workflow type instead of patching: + +```php +// Original interface +#[WorkflowInterface] +interface PizzaWorkflowInterface +{ + #[WorkflowMethod(name: 'PizzaWorkflow')] + public function run(array $order): \Generator; +} + +// New interface for incompatible changes +#[WorkflowInterface] +interface PizzaWorkflowV2Interface +{ + #[WorkflowMethod(name: 'PizzaWorkflowV2')] + public function run(array $order): \Generator; +} +``` + +Register both with the Worker: + +```php +$worker = $factory->newWorker('pizza-task-queue'); +$worker->registerWorkflowTypes(PizzaWorkflow::class); +$worker->registerWorkflowTypes(PizzaWorkflowV2::class); +``` + +Start new executions using the new type: + +```php +$workflow = $client->newWorkflowStub( + PizzaWorkflowV2Interface::class, + WorkflowOptions::new()->withTaskQueue('pizza-task-queue') +); +$result = $workflow->run($order); +``` + +Check for open executions before removing the old type: + +```bash +temporal workflow list --query 'WorkflowType = "PizzaWorkflow" AND ExecutionStatus = "Running"' +``` + +## Worker Versioning + +Worker Versioning manages versions at the deployment level, allowing multiple Worker versions to run simultaneously. PHP uses the same concepts as other SDKs: Worker Deployment name, Build ID, PINNED vs AUTO_UPGRADE behaviors. + +> **Note:** Worker Versioning is currently in Public Preview. The legacy Worker Versioning API (before 2025) will be removed from Temporal Server in March 2026. + +Configure a versioned Worker in the RoadRunner worker setup: + +```php +$factory = WorkerFactory::create(); + +$worker = $factory->newWorker( + taskQueue: 'order-service', + deploymentOptions: WorkerDeploymentOptions::new() + ->withDeploymentName('order-service') + ->withBuildId('v1.0.0') // git commit hash or semver + ->withUseWorkerVersioning(true) +); + +$worker->registerWorkflowTypes(OrderWorkflow::class); +$worker->registerActivity(OrderActivity::class); + +$factory->run(); +``` + +Use the Temporal CLI to set the current version: + +```bash +temporal worker deployment set-current-version \ + --deployment-name order-service \ + --build-id v1.0.0 +``` + +## Best Practices + +1. **Check for open executions** before removing old code paths +2. **Use descriptive change IDs** that explain the change (e.g., `add-fraud-check` not `patch-1`) +3. **Deploy incrementally**: patch in, deprecate (remove old branch), remove version call +4. **Use `yield` on `getVersion()`** — it is a coroutine and must be awaited +5. **Use List Filters** to verify no running Workflows before removing version support From 2401665931d6f79269b99629d2324e68f18d5588 Mon Sep 17 00:00:00 2001 From: Donald Pinckney Date: Mon, 16 Mar 2026 18:39:37 -0400 Subject: [PATCH 2/4] Fix PHP alignment issues from self-review - Renamed sections to match Python reference style (6 section name fixes) - observability.md: replaced full Search Attributes content with ref to data-handling.md - gotchas.md: removed extra subsection already covered in php.md - versioning.md: expanded Worker Versioning to match Python verbosity Co-Authored-By: Claude Opus 4.6 (1M context) --- references/php/data-handling.md | 2 +- references/php/error-handling.md | 6 ++--- references/php/gotchas.md | 16 ------------- references/php/observability.md | 23 ++---------------- references/php/patterns.md | 2 +- references/php/testing.md | 6 ++--- references/php/versioning.md | 41 +++++++++++++++++++++++++++++--- 7 files changed, 48 insertions(+), 48 deletions(-) diff --git a/references/php/data-handling.md b/references/php/data-handling.md index 9d2b84a..da8c8bc 100644 --- a/references/php/data-handling.md +++ b/references/php/data-handling.md @@ -28,7 +28,7 @@ interface OrderWorkflowInterface Without `#[ReturnType]`, the SDK cannot deserialize the result into the correct class. -## Custom Data Converter +## Custom Data Conversion Implement a custom `PayloadConverter` to handle types the default converter does not support: diff --git a/references/php/error-handling.md b/references/php/error-handling.md index 895f551..5689947 100644 --- a/references/php/error-handling.md +++ b/references/php/error-handling.md @@ -4,7 +4,7 @@ The PHP SDK uses `ApplicationFailure` for application-specific errors and provides retry policy configuration via `RetryOptions`. Generally, the following information about errors and retryability applies across activities, child workflows, and Nexus operations. -## Application Errors/Failures +## Application Errors ```php use Temporal\Exception\Failure\ApplicationFailure; @@ -43,7 +43,7 @@ public function chargeCard(ChargeCardInput $input): string } ``` -## Handling Activity Errors in Workflows +## Handling Activity Errors ```php use Temporal\Exception\Failure\ApplicationFailure; @@ -68,7 +68,7 @@ public function run(): string Activities throw exceptions; workflows catch `ActivityFailure` (which wraps the original exception). -## Retry Configuration +## Retry Policy Configuration ```php use Temporal\Activity\ActivityOptions; diff --git a/references/php/gotchas.md b/references/php/gotchas.md index fd9d722..ca5cfab 100644 --- a/references/php/gotchas.md +++ b/references/php/gotchas.md @@ -138,19 +138,3 @@ public function run(): \Generator ``` **Why this matters:** `sleep()` uses the system clock, which differs between original execution and replay. `Workflow::timer()` creates a durable timer in the event history, ensuring consistent behavior during replay. - -### Other PHP-Specific Determinism Mistakes - -```php -// BAD: no yield on activity call — fire-and-forget, result is lost -$this->myActivity->doSomething(); - -// GOOD: always yield activity calls -$result = yield $this->myActivity->doSomething(); - -// BAD: time() uses wall clock — non-deterministic -$now = time(); - -// GOOD: use Workflow::now() for deterministic time -$now = Workflow::now(); // Returns \DateTimeImmutable -``` diff --git a/references/php/observability.md b/references/php/observability.md index 1aebc03..1967f45 100644 --- a/references/php/observability.md +++ b/references/php/observability.md @@ -4,7 +4,7 @@ The PHP SDK provides observability through PSR-3 logging (with replay-aware Workflow logger), and visibility via Search Attributes. -## Logging / Replay-Aware Logging +## Logging ### Workflow Logging @@ -90,26 +90,7 @@ Any PSR-3 compatible logger (Monolog, etc.) can be used. ## Search Attributes (Visibility) -Use Search Attributes to make Workflow executions queryable by business fields. See `references/php/data-handling.md` for how to set and upsert Search Attributes. - -Query Workflow executions using Search Attributes: - -```php -$executions = $client->listWorkflowExecutions( - 'WorkflowType = "OrderWorkflow" AND ExecutionStatus = "Running" AND OrderStatus = "pending"' -); - -foreach ($executions as $execution) { - echo "Pending order: {$execution->getExecution()->getWorkflowId()}\n"; -} -``` - -Or using the Temporal CLI: - -```bash -temporal workflow list --query \ - 'WorkflowType = "OrderWorkflow" AND ExecutionStatus = "Running" AND OrderStatus = "pending"' -``` +See the Search Attributes section of `references/php/data-handling.md` ## Best Practices diff --git a/references/php/patterns.md b/references/php/patterns.md index 236d978..896e1a9 100644 --- a/references/php/patterns.md +++ b/references/php/patterns.md @@ -383,7 +383,7 @@ class HandlerAwareWorkflow } ``` -## Activity Heartbeating +## Activity Heartbeat Details ### WHY: - **Support activity cancellation** — Cancellations are delivered via heartbeat; activities that don't heartbeat won't know they've been cancelled diff --git a/references/php/testing.md b/references/php/testing.md index d806d8f..3d2d8fa 100644 --- a/references/php/testing.md +++ b/references/php/testing.md @@ -4,7 +4,7 @@ You test Temporal PHP Workflows using PHPUnit with the Temporal testing package. The PHP SDK provides `WorkerFactory` from `Temporal\Testing` and a RoadRunner test server for running workflows in an isolated environment. -## Test Environment Setup +## Workflow Test Environment Set up a `bootstrap.php` to initialize the test environment: @@ -57,7 +57,7 @@ class MyWorkflowTest extends TestCase } ``` -## Activity Mocking +## Mocking Activities Use `ActivityMocker` to mock activities without executing their real implementation: @@ -148,7 +148,7 @@ public function testActivityFailureHandling(): void } ``` -## Replay Testing +## Workflow Replay Testing Use `WorkflowReplayer` to verify workflow determinism against recorded histories: diff --git a/references/php/versioning.md b/references/php/versioning.md index d5e5d37..598baaf 100644 --- a/references/php/versioning.md +++ b/references/php/versioning.md @@ -153,11 +153,17 @@ temporal workflow list --query 'WorkflowType = "PizzaWorkflow" AND ExecutionStat ## Worker Versioning -Worker Versioning manages versions at the deployment level, allowing multiple Worker versions to run simultaneously. PHP uses the same concepts as other SDKs: Worker Deployment name, Build ID, PINNED vs AUTO_UPGRADE behaviors. +Worker Versioning manages versions at the deployment level, allowing multiple Worker versions to run simultaneously. -> **Note:** Worker Versioning is currently in Public Preview. The legacy Worker Versioning API (before 2025) will be removed from Temporal Server in March 2026. +### Key Concepts + +**Worker Deployment**: A logical service grouping similar Workers together (e.g., "order-service"). All versions of your code live under this umbrella. + +**Worker Deployment Version**: A specific snapshot of your code identified by a deployment name and Build ID (e.g., "order-service:v1.0.0" or "order-service:abc123"). + +### Configuring Workers for Versioning -Configure a versioned Worker in the RoadRunner worker setup: +> **Note:** Worker Versioning is currently in Public Preview. The legacy Worker Versioning API (before 2025) will be removed from Temporal Server in March 2026. ```php $factory = WorkerFactory::create(); @@ -176,6 +182,27 @@ $worker->registerActivity(OrderActivity::class); $factory->run(); ``` +**Configuration parameters:** +- `withUseWorkerVersioning`: Enables Worker Versioning +- `withDeploymentName`: Logical name for your service (consistent across versions) +- `withBuildId`: Unique identifier for this build (git hash, semver, etc.) + +### PINNED vs AUTO_UPGRADE Behaviors + +**When to use PINNED:** +- Short-running workflows (minutes to hours) +- Consistency is critical (e.g., financial transactions) +- You want to eliminate version compatibility complexity +- Building new applications and want simplest development experience + +**When to use AUTO_UPGRADE:** +- Long-running workflows (weeks or months) +- Workflows need to benefit from bug fixes during execution +- Migrating from traditional rolling deployments +- You are already using patching APIs for version transitions + +**Important:** AUTO_UPGRADE workflows still need patching to handle version transitions safely since they can move between Worker versions. + Use the Temporal CLI to set the current version: ```bash @@ -184,6 +211,14 @@ temporal worker deployment set-current-version \ --build-id v1.0.0 ``` +### Querying Workflows by Worker Version + +```bash +# Find workflows on a specific Worker version +temporal workflow list --query \ + 'TemporalWorkerDeploymentVersion = "order-service:v1.0.0" AND ExecutionStatus = "Running"' +``` + ## Best Practices 1. **Check for open executions** before removing old code paths From 30081f1e5659ba28424d76368f61f1635334af34 Mon Sep 17 00:00:00 2001 From: Donald Pinckney Date: Mon, 16 Mar 2026 18:49:40 -0400 Subject: [PATCH 3/4] Fix correctness issues in PHP reference files - patterns.md: Fixed dynamic handler params (array -> ValuesInterface), continueAsNew signature (added yield, string type + array args), heartbeat details API (hasHeartbeatDetails + typed getHeartbeatDetails) - data-handling.md: Fixed SearchAttributeKey import path (added SearchAttributes sub-namespace), TypedSearchAttributes::new() + withSearchAttribute(), valueSet() instead of withValue() Co-Authored-By: Claude Opus 4.6 (1M context) --- references/php/data-handling.md | 12 ++++++------ references/php/patterns.md | 18 ++++++++++++------ 2 files changed, 18 insertions(+), 12 deletions(-) diff --git a/references/php/data-handling.md b/references/php/data-handling.md index da8c8bc..86257ef 100644 --- a/references/php/data-handling.md +++ b/references/php/data-handling.md @@ -140,7 +140,7 @@ Custom searchable fields for Workflow visibility. Define Search Attribute keys and set them at Workflow start: ```php -use Temporal\Common\SearchAttributeKey; +use Temporal\Common\SearchAttributes\SearchAttributeKey; use Temporal\Common\TypedSearchAttributes; $orderIdKey = SearchAttributeKey::forKeyword('OrderId'); @@ -151,9 +151,9 @@ $workflow = $client->newWorkflowStub( WorkflowOptions::new() ->withTaskQueue('orders') ->withTypedSearchAttributes( - TypedSearchAttributes::empty() - ->withValue($orderIdKey, $order->id) - ->withValue($orderStatusKey, 'pending') + TypedSearchAttributes::new() + ->withSearchAttribute($orderIdKey, $order->id) + ->withSearchAttribute($orderStatusKey, 'pending') ) ); ``` @@ -162,7 +162,7 @@ Upsert Search Attributes during Workflow execution: ```php use Temporal\Workflow; -use Temporal\Common\SearchAttributeKey; +use Temporal\Common\SearchAttributes\SearchAttributeKey; class OrderWorkflow implements OrderWorkflowInterface { @@ -171,7 +171,7 @@ class OrderWorkflow implements OrderWorkflowInterface // ... process order ... Workflow::upsertTypedSearchAttributes( - SearchAttributeKey::forKeyword('OrderStatus')->withValue('completed') + SearchAttributeKey::forKeyword('OrderStatus')->valueSet('completed') ); return 'done'; diff --git a/references/php/patterns.md b/references/php/patterns.md index 896e1a9..d77c9cc 100644 --- a/references/php/patterns.md +++ b/references/php/patterns.md @@ -41,6 +41,8 @@ class OrderWorkflow For handling signals with names not known at compile time. Use cases for this pattern are rare — most workflows should use statically defined signal handlers. ```php +use Temporal\DataConverter\ValuesInterface; + #[WorkflowInterface] class DynamicSignalWorkflow { @@ -48,8 +50,8 @@ class DynamicSignalWorkflow public function __construct() { - Workflow::registerDynamicSignal(function (string $name, array $args): void { - $this->signals[$name][] = $args[0] ?? null; + Workflow::registerDynamicSignal(function (string $name, ValuesInterface $arguments): void { + $this->signals[$name][] = $arguments->getValue(0); }); } @@ -116,7 +118,7 @@ class DynamicQueryWorkflow public function __construct() { - Workflow::registerDynamicQuery(function (string $name, array $args): mixed { + Workflow::registerDynamicQuery(function (string $name, ValuesInterface $arguments): mixed { return $this->state[$name] ?? null; }); } @@ -278,7 +280,10 @@ class LongRunningWorkflow // Continue with fresh history before hitting limits if (Workflow::getInfo()->shouldContinueAsNew) { - return Workflow::continueAsNew($state); + return yield Workflow::continueAsNew( + 'LongRunningWorkflow', + [$state] + ); } } } @@ -407,8 +412,9 @@ class FileProcessingActivities public function processLargeFile(string $filePath): string { // Get heartbeat details from previous attempt (if any) - $heartbeatDetails = Activity::getHeartbeatDetails(); - $startLine = $heartbeatDetails[0] ?? 0; + $startLine = Activity::hasHeartbeatDetails() + ? Activity::getHeartbeatDetails('int') + : 0; $lines = file($filePath); From fdd196035bdabc8507cc7c904b5068083ac15ce6 Mon Sep 17 00:00:00 2001 From: Donald Pinckney Date: Mon, 16 Mar 2026 18:51:03 -0400 Subject: [PATCH 4/4] Add PHP to SKILL.md and core determinism references - SKILL.md: Added "Temporal PHP" trigger phrase, updated overview to include PHP, added PHP getting started guide reference - core/determinism.md: Added PHP entry to SDK Protection Mechanisms describing runtime command-ordering checks (no sandbox) Co-Authored-By: Claude Opus 4.6 (1M context) --- SKILL.md | 5 +++-- references/core/determinism.md | 1 + 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/SKILL.md b/SKILL.md index 6c36f07..eb8d590 100644 --- a/SKILL.md +++ b/SKILL.md @@ -1,6 +1,6 @@ --- name: temporal-developer -description: This skill should be used when the user asks to "create a Temporal workflow", "write a Temporal activity", "debug stuck workflow", "fix non-determinism error", "Temporal Python", "Temporal TypeScript", "workflow replay", "activity timeout", "signal workflow", "query workflow", "worker not starting", "activity keeps retrying", "Temporal heartbeat", "continue-as-new", "child workflow", "saga pattern", "workflow versioning", "durable execution", "reliable distributed systems", or mentions Temporal SDK development. +description: This skill should be used when the user asks to "create a Temporal workflow", "write a Temporal activity", "debug stuck workflow", "fix non-determinism error", "Temporal Python", "Temporal TypeScript", "Temporal PHP", "workflow replay", "activity timeout", "signal workflow", "query workflow", "worker not starting", "activity keeps retrying", "Temporal heartbeat", "continue-as-new", "child workflow", "saga pattern", "workflow versioning", "durable execution", "reliable distributed systems", or mentions Temporal SDK development. version: 1.0.0 --- @@ -8,7 +8,7 @@ version: 1.0.0 ## Overview -Temporal is a durable execution platform that makes workflows survive failures automatically. This skill provides guidance for building Temporal applications in Python and TypeScript. +Temporal is a durable execution platform that makes workflows survive failures automatically. This skill provides guidance for building Temporal applications in Python, TypeScript, and PHP. ## Core Architecture @@ -92,6 +92,7 @@ Once you've downloaded the file, extract the downloaded archive and add the temp 1. First, read the getting started guide for the language you are working in: - Python -> read `references/python/python.md` - TypeScript -> read `references/typescript/typescript.md` + - PHP -> read `references/php/php.md` 2. Second, read appropriate `core` and language-specific references for the task at hand. diff --git a/references/core/determinism.md b/references/core/determinism.md index bf4f1ec..5ebb54d 100644 --- a/references/core/determinism.md +++ b/references/core/determinism.md @@ -80,6 +80,7 @@ Each Temporal SDK language provides a protection mechanism to make it easier to - Python: The Python SDK runs workflows in a sandbox that intercepts and aborts non-deterministic calls at runtime. - TypeScript: The TypeScript SDK runs workflows in an isolated V8 sandbox, intercepting many common sources of non-determinism and replacing them automatically with deterministic variants. +- PHP: The PHP SDK performs runtime checks that detect adding, removing, or reordering of commands (activity calls, timers, child workflows, etc.). It does NOT have a sandbox — developers must be disciplined about avoiding non-deterministic operations in workflow code. ## Detecting Non-Determinism