PHP SDK guide

Workflows & activities

Workflow code decides what should happen. Activity code performs the fallible outside work. The Server records enough history to replay the decision code safely after a crash.

Keep workflow code deterministic

A workflow handler runs as ordinary straight-line PHP inside an isolated Fiber. Calls on WorkflowContext pause internally when durable work is pending; on replay, the SDK walks committed history and returns recorded values directly from those calls.

For repeated ordered application input, open an inbound Message Stream from WorkflowContext. Its durable cursor resumes after replay, worker replacement, and continue-as-new.

Use WorkflowContext::waitCondition() when progress depends on workflow state rather than an external activity. Its deterministic predicate is re-evaluated when committed signals or updates produce another workflow task. A stable key identifies the wait across replay, and the optional timeout returns false instead of requiring an application timer loop. The timeout runs from the first time the wait opens: a signal or update that leaves the predicate false does not restart it. When the Server advertises condition_wait_occurrence_deadline, the Server also keeps that first open's wall-clock deadline for every reopen; see the upgrade guide for the rollout order.

Choose a distinct stable key for each logical wait, and preserve its predicate and timeout across replay. An already true condition, or a false condition with timeout: 0, returns without opening a new server wait. Those immediate returns do not consume a later recorded operation. A recorded wait result or canonical cancellation decision still governs the original call.

$approved = $context->waitCondition(
    static fn (): bool => $context->signals('approve') !== [],
    key: 'order-approved',
    timeout: 300,
);

if (!$approved) {
    return ['status' => 'timed_out'];
}

Durations

sleep() and deferTimer(), the timeout of waitCondition() and deferCondition(), the remote and local activity timeout options, the child workflow execution_timeout_seconds and run_timeout_seconds options, and the serviceOperation() wait_timeout_seconds option take a duration in any of these forms:

use Carbon\CarbonInterval;

$verified = $context->waitCondition(
    static fn (): bool => $context->signals('verify') !== [],
    key: 'email-verified',
    timeout: '30 minutes',
);

$context->sleep(CarbonInterval::hours(2));

$receipt = $context->activity('payments.capture', [$orderId], [
    'start_to_close_timeout' => '30 seconds',
    'schedule_to_close_timeout' => '10 minutes',
]);

The SDK converts each duration to whole seconds once, rounding up, so '30 minutes', 1800, new DateInterval('PT30M') and CarbonInterval::minutes(30) record identical history. Strings convert exactly as the embedded Laravel package's timer() and await() do, with ceil(CarbonInterval::fromString($duration)->totalSeconds). Intervals are measured by their year, month, day, hour, minute, second and microsecond fields with CarbonInterval::instance()->totalSeconds.

Carbon converts with its cascade factors, not the calendar: by default a day is 24 hours, a week 7 days, a month 4 weeks and a year 12 months. Those factors do not depend on the current date, time zone or daylight-saving changes. Changing the factors with CarbonInterval::setCascadeFactors() changes the result, in the SDK and in the embedded package, so leave them at their defaults in worker processes.

Conversion retains the installed Carbon major's floating-point behavior. Very large accepted durations with millisecond or microsecond parts can produce different whole-second results across Carbon majors. For example, '5555555555555 seconds 32037 milliseconds' converts to 5555555555588 seconds on Carbon 2 and 5555555555556 on Carbon 3. Each matches the embedded package on that same Carbon major.

Regression samples span base durations from 10 billion seconds through the reported value above, with cases around 100 billion and one trillion seconds. These sample points do not establish a universal precision bound. Even a one-microsecond part can disappear before rounding at large magnitudes. Keep the Carbon major consistent when workers replay runs using such duration strings; explicit integer seconds avoid this string-conversion difference.

A CarbonInterval from diff() is measured by its fields too. For example, the interval from January 1 to February 10, 01:00 is 1 month, 9 days and 1 hour, so it converts to 37 days and 1 hour. On Carbon 3, the embedded package measures the 40-day span between the interval's dates instead. Pass $start->diffInSeconds($end) when you need the real span.

These are the string units, matched case-insensitively:

Unit Spellings
Microseconds microsecond, microseconds, micro, µs
Milliseconds millisecond, milliseconds, milli, ms
Seconds second, seconds, s
Minutes minute, minutes, m
Hours hour, hours, h
Days day, days, d
Weeks week, weeks, w
Months month, months, mo, mos
Quarters quarter, quarters
Years year, years, y, yr, yrs
Decades decade, decades
Centuries century, centuries
Millennia millennium, millennia

Parts may be separated by spaces or and, such as '1 hour and 30 minutes', and may use a decimal fraction, such as '1.5 hours'. A fraction of a month, quarter, year, decade, century or millennium is rejected, because Carbon would truncate it. Unknown units, extra words, commas, negative parts, parts with more than 15 digits before the decimal point, durations of 2^53 seconds or more (about 310 million years as Carbon counts them, with 336-day years), and ISO 8601 strings throw InvalidArgumentException naming the parameter and the accepted forms. Pass ISO 8601 durations as new DateInterval('PT30M'). Negative intervals and relative DateInterval::createFromDateString() values such as 'next weekday' or 'last day of next month' are rejected too.

A numeric string such as '30' or '1.5' converts as PHP coerced it before string durations were accepted, in a file without strict_types. Timers and condition timeouts round it up to whole seconds, with negative values becoming zero. Activity and child-workflow options pass it to the Server unchanged. In a file with declare(strict_types=1), such a string used to throw a TypeError; it is now accepted with the same conversion.

A zero duration is valid for timers, condition waits and wait_timeout_seconds. Activity and child-workflow timeouts need at least one second.

Keep a timer's duration stable across deployments. Changing '30 minutes' to '31 minutes' while a run is waiting on that timer raises NonDeterministicWorkflow on replay, just as changing 1800 to 1860 would.

$worker->registerWorkflow(
    'orders.process',
    static function (WorkflowContext $context, array $order): array {
        $reservation = $context->activity(
            'inventory.reserve',
            [$order['id']],
            ['start_to_close_timeout' => 20],
        );

        $context->sleep(5);

        return ['order' => $order['id'], 'reservation' => $reservation];
    },
);

Remote activity options are connection, queue, retry_policy, start_to_close_timeout, schedule_to_start_timeout, schedule_to_close_timeout, heartbeat_timeout, worker_session and cancellation_policy. The four timeouts accept the duration forms above. Unknown option or retry-policy field names fail locally with InvalidArgumentException. See failures and retries for the retry policy fields.

The sequence, type, and details of context operations are durable. Changing them while old histories are still running can raise NonDeterministicWorkflow. Deploy replay-compatible code or drain incompatible histories before removing an old branch.

Keep invocation state local

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.

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:

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

Use serviceOperation() inside a workflow to invoke a Server service endpoint. The call pauses until Server records its result. Worker restarts replay the same call identity and result without submitting another operation.

use DurableWorkflow\Exception\ServiceOperationFailed;
use DurableWorkflow\Model\ServiceOperationOptions;
use DurableWorkflow\Worker\WorkflowContext;

function invoice(WorkflowContext $context, int $invoiceId): mixed
{
    try {
        $call = $context->serviceOperation('billing', 'invoices', 'create',
            ['invoice_id' => $invoiceId],
            new ServiceOperationOptions(waitTimeoutSeconds: 30, targetNamespace: 'billing'));

        return $call->responsePayload;
    } catch (ServiceOperationFailed $failure) {
        return ['call_id' => $failure->serviceCallId,
            'error' => $failure->failureType, 'cancelled' => $failure->cancelled];
    }
}

Register this handler with registerWorkflow() as usual. The endpoint, service and operation must already exist, and the worker credential must have permission to call them. targetNamespace selects the target namespace and serializes to the same namespace field as array options. Omit it to use the caller's namespace. Empty targets are invalid. Server assigns caller identity from the leased run; target selection does not grant caller authority. For direct client service calls, use $client->withNamespace('billing') instead of this workflow option.

The default options are mode_override=sync and wait_for=completed. Use wait_for=accepted or mode_override=async to receive the original admission instead. Its serviceCallId identifies the operation and its serviceCall map contains the recorded diagnostics. A later completion cannot replace that original asynchronous result during replay.

wait_timeout_seconds is the Server wait budget, not a sleep in the SDK. Replay does not start a fresh budget. Keep the service names, request, routing and wait options stable for open runs, or guard changes with workflow version markers. Service operations currently run sequentially outside cancellation scopes and operation groups. The worker reports service_operations_unavailable when the connected runtime does not advertise the command.

With cooperative cancellation enabled, a completion-waiting call can deliver cancellation at its original service-call boundary. The runtime must advertise nexus_cooperative_cancellation for the workflow_run binding. The caller keeps its original cleanup deadline while its owned target finishes cleanup. Admission-only calls do not gain this waiting boundary, and returning an existing workflow does not grant cancellation ownership over it.

Fan out durable work and join it once

all() (also available as parallel()) captures one durable operation from each closure, schedules every leaf in one workflow-task completion, then suspends the Fiber until the group can join. The returned array always follows declaration order, even when the Server records completions in another order:

[$profile, [$recommendations, $delay]] = $context->all([
    static fn () => $context->activity('customers.profile', [$customerId]),
    static fn () => $context->parallel([
        $context->deferChildWorkflow('recommendations.build', [$customerId]),
        $context->deferTimer(1),
    ]),
]);

Use either a closure containing one activity(), childWorkflow(), or sleep() call, or the corresponding deferActivity(), deferChildWorkflow(), or deferTimer() value. The normal API never requires protocol command arrays or yield. Mixed and nested groups retain their input shape, and one outer group may contain at most WorkflowContext::MAX_PARALLEL_OPERATIONS (1,000) durable leaves. An empty top-level group returns []; an empty nested barrier fails before transport because no history identity could prove that nested shape during replay.

The barrier is fail-fast. The first failure in durable history order is thrown into the workflow as ActivityFailed, ChildWorkflowFailed, or WorkflowCancelled; a tie uses declaration order. Every sibling was already scheduled, so fail-fast does not cancel it. A late or duplicate terminal event cannot reorder results or replace the first recorded terminal outcome. There is deliberately no collect-all barrier: model expected per-member errors as activity or child results when the workflow needs every outcome as data.

On retry or worker restart, replay validates the exact group ID, base sequence, size, leaf index, and nested path. Pending or completed members keep their original sequence and are not scheduled again. Changing a dynamic fan-out count, order, operation kind, or nesting while an open run can reach the group raises NonDeterministicWorkflow before any replacement commands leave the worker.

Start a child and keep its identity

childWorkflow() returns the child's result, so the parent waits until the child finishes. When the parent needs the child's identity first, for example to record it or notify someone, start the child with startChildWorkflow():

use DurableWorkflow\Worker\ParentClosePolicy;

$child = $context->startChildWorkflow('billing.reconcile', [$accountId], [
    'parent_close_policy' => ParentClosePolicy::Abandon,
]);

$identity = $child->identity();
$context->activity('notify.reconcile-started', [$identity->workflowId]);

$summary = $child->await();

The turn that calls startChildWorkflow() ends after the start command. The runtime commits the child's start and gives the parent a new turn while the child keeps running, so the handle returns only for a committed child. identity() reads canonical history:

Property Meaning
callId The parent's call for this child. It stays the same through child continue-as-new and retries.
workflowId The child workflow instance ID.
initialRunId The first run of the child. A later continued or retried run does not change it.

await() returns the child's result or throws ChildWorkflowFailed. It follows the same child call through continue-as-new and retries, and sends no command of its own. Replay and a replacement worker rebuild the same handle from history without starting another child.

Parent-close and cancellation policies, timeouts and retries work as they do for childWorkflow(). The handle supports only identity() and await(): it cannot signal, query, update, cancel or terminate the child. Await it in the workflow execution that started it. startChildWorkflow() cannot run inside an all() or parallel() group; use deferChildWorkflow() there.

This needs a runtime that advertises child_workflow_start_turn. Native Server 3.0.x and PHP Server 2.x do not. On those, the worker abandons the task with child_workflow_start_turn_unavailable before it submits anything, so no child starts. A cancellation-scope checkpoint keeps the parent's claim, so the runtime refuses a start there with child_start_turn_not_at_checkpoint.

Run the service-mode child example

The complete worker and client use the quickstart's bootstrap. Copy those three files beside the vendor/ directory of your Composer project. Use an SDK release containing startChildWorkflow() and a Server advertising child_workflow_start_turn. The same runtime URL and namespace must be available to both processes. Set DURABLE_WORKFLOW_RUNTIME_URL, DURABLE_WORKFLOW_NAMESPACE, and DURABLE_WORKFLOW_TASK_QUEUE in both terminals. Set DURABLE_WORKFLOW_WORKER_TOKEN in the worker terminal and DURABLE_WORKFLOW_CLIENT_TOKEN in the client terminal, using their respective role credentials.

Start the worker, then run a client command in the second terminal:

php child-workflow-worker.php
php child-workflow-client.php await
php child-workflow-client.php await reject
php child-workflow-client.php abandon
php child-workflow-client.php cancel
php child-workflow-client.php terminate

Each parent first demonstrates a sequential result. It then starts a child with a committed handle and records the identity in an activity. The activity may be retried, so an application replacing its sample output with an external write should make that write idempotent by call_id.

await returns the typed greeting. await reject catches ChildWorkflowFailed and returns its recorded failure. The other modes close the parent while its child remains open: abandon allows it to complete, cancel applies legacy terminal cancellation, and terminate terminates it. The client prints the parent result and the child's observed status. Cancellation through the handle is unsupported. Cooperative cleanup uses the separate opt-in cancellation APIs and ParentClosePolicy::RequestCancellation, as described in the cancellation guide. Initial run IDs remain snapshots through retry and continue-as-new; instance-oriented client operations target the child's current run unless the caller explicitly selects a run.

deferChildWorkflow() prepares an operation without starting it or providing a child identity. Join it at an all() or parallel() barrier on a runtime that supports child members in those groups, as shown in the concurrency examples above. Native Server 3.2 supports a flat all() (or parallel()) barrier whose members are all deferred children. It refuses other child group shapes with development_parallel_group_unqualified: select(), nested or mixed groups, groups inside a cancellation scope, and committed-start handles inside a barrier. Earlier Native Server releases refuse child members in groups. The runnable example uses sequential children and committed start handles; it does not demonstrate a deferred group.

Evolve workflow code with version markers

Give each long-lived code change a stable ID and keep the old branch while histories can still reach it. getVersion() records the maximum supported version for a new run and returns that same decision after redelivery, worker restart, or cold replay. Including -1 in the first range lets histories that passed this point before the marker existed stay on the legacy branch:

$version = $context->getVersion('inventory-reservation', -1, 1);

$reservation = $version === -1
    ? $context->activity('inventory.reserve-legacy', [$orderId])
    : $context->activity('inventory.reserve', [$orderId]);

A later deployment can raise the maximum while keeping the recorded versions in range. Existing runs keep their decision; only runs that have not recorded this change ID select the new maximum.

For a boolean rollout, use patched('require-reservation-review'). It returns false for legacy histories and true for new histories. After the legacy branch is no longer supported, replace the conditional call with deprecatePatch() for the same ID before eventually removing the marker. Compatible numeric and patch helpers share the same frozen version for a change ID. Every call must support that version, and patched() returns whether it equals 1. Do not reuse a change ID for an unrelated rollout.

A waitCondition() predicate is part of replay identity too. New waits use a dwphp2:sha256: fingerprint of the callable's tokens. Whitespace and comments outside literals, file locations and neighboring call-site arguments are excluded. Changing the predicate's tokens still raises NonDeterministicWorkflow, even for a wait that has already been satisfied. The key and timeout remain separate replay checks; change them behind a version marker when an open run can reach the call.

Reflection can leave several same-line closures indistinguishable. Those closures share a combined candidate fingerprint, so changing either can affect both. Formatting that resolves that ambiguity can also change the candidate set. Put predicates on separate lines before starting runs when they need independent identities. If the callable cannot be isolated, or PHP lacks the tokenizer, the worker retains the v1 source-line fingerprint. Every worker that replays v2 waits needs PHP's tokenizer enabled.

Waits first opened by older SDKs retain their exact v1 source-line check. Re-indenting those lines, changing a comment or editing neighboring code on the same lines can still fail legacy replay. A physical reopen echoes the first recorded fingerprint, so upgrading does not change an existing wait's identity. Upgrade the queue's workers together: older workers reject v2 fingerprints, and cannot safely take over runs that already contain them.

Deploy workflow code atomically and restart workers. The fingerprint reads the deployed source file. If files are replaced in place under any long-running worker that still has the old code loaded, the loaded code and the file on disk can disagree, and replay reports a mismatch until the worker restarts. opcache.validate_timestamps=0 widens this window. Ship the PHP sources with the worker image; the fingerprint cannot be computed without them.

The upgrade guide covers the queue-wide deployment and rollback boundary when both legacy and v2 condition histories exist.

Put side effects in activities

$worker->registerActivity(
    'inventory.reserve',
    static function (ActivityContext $context, string $orderId): array {
        $context->heartbeat(['order_id' => $orderId, 'stage' => 'starting']);

        return reserveInventory($orderId);
    },
);

Activities may call databases, HTTP services, queues, and the filesystem. An activity attempt can run again after a retryable failure, so use an idempotency key tied to the workflow, activity, or business identity at the downstream boundary.

Compensate completed work with a saga

Create a saga inside the workflow Fiber and register each compensation only after its forward activity returns. run() catches a later failure, schedules the registered activities in reverse order, then preserves the original failure as the workflow outcome:

use DurableWorkflow\Worker\Saga;
use DurableWorkflow\Worker\WorkflowContext;

return $context->saga()->run(
    static function (Saga $saga) use ($context, $tripId): array {
        $flight = $context->activity('trip.reserve-flight', [$tripId]);
        $saga->addCompensation('trip.cancel-flight', [$flight]);

        $hotel = $context->activity('trip.reserve-hotel', [$tripId]);
        $saga->addCompensation('trip.cancel-hotel', [$hotel]);

        $context->activity('trip.charge', [$tripId]);

        return compact('flight', 'hotel');
    },
);

Compensations use the same arguments, queue, timeout, and retry-policy options as WorkflowContext::activity(). They can therefore be implemented by a PHP, Python, or Rust worker registered on the selected queue; there is no PHP-specific Server command.

The activity history is the durable compensation record. On replay the workflow recreates the same registrations, consumes any compensation activity that already completed, and resumes at the next reverse-order step. Keep the forward decision order and each compensation type stable while a run is open.

Saga::run() compensates ActivityFailed, cancellation thrown by throwIfCancellationRequested(), and other escaping failures. Successful compensation rethrows the original failure. A failed compensation throws SagaCompensationFailed, whose typed fields retain the forward failure, compensation failure, compensation activity type, and registration order. Service-mode compensation is always sequential LIFO and stops at that first terminal compensation failure, so an earlier registered compensation is not scheduled after the failure. Nested sagas own separate registration lists: an inner saga compensates first; if its failure escapes, the outer saga then compensates its own completed work.

For an embedded-style workflow that intentionally completes with a compensated result, keep the explicit catch and call compensate($failure) before returning that result. Repeated calls after a successful pass are no-ops:

$saga = $context->saga();

try {
    // Run forward activities and call $saga->addCompensation(...) after each success.
} catch (\DurableWorkflow\Exception\ActivityFailed $failure) {
    $saga->compensate($failure);

    return ['status' => 'compensated', 'reason' => $failure->getMessage()];
}

ActivityContext::heartbeat() records progress and throws ActivityCancelled when the Server requests cancellation. Heartbeat before and during long calls that can be safely divided into chunks.

Workers that opt into cooperative cancellation supervise callbacks and observe cancellation independently of application heartbeats. Prepared local activities use supervised callback processes.

Inspect retry budget and the owning workflow

The ActivityContext::activityExecutionId property contains the logical activity_execution_id received with the claim. Ordinary and cooperatively supervised remote callbacks expose that same execution identity across retry attempts, while activityAttemptId identifies each attempt. Use the logical ID in a downstream idempotency key when the destination honors that key, including your application scope where needed.

function activityIdempotencyKey(
    \DurableWorkflow\Worker\ActivityContext $context,
    string $businessKey,
): string {
    return $context->activityExecutionId ?? $businessKey;
}

Older claims can omit the field. Missing, null, empty or non-string values produce null, so supply a business key when it is unavailable. Prepared local callbacks expose the execution ID from their validated Server admission. Inline local and Server-free callbacks without that admission report null. Session activity callbacks expose the field when their actual claim includes it; omission also produces null. The property is read-only and performs no additional runtime request.

PHP SDK 3.6.0 introduced retry metadata on ActivityContext. Use 3.6.1 or later for the original owning workflow and run IDs from public activity claims. $context->retryInfo retains the original attempt number and retry policy, including unknown fields. maxAttempts() returns a positive configured limit when available. Use isUnlimited() to distinguish an explicit max_attempts: null policy from an unknown limit. policyProvided distinguishes an omitted snapshot from a received legacy null; policy() preserves either received value without filling in defaults.

isLastConfiguredAttempt() returns true at or beyond a bounded final attempt, false for a remaining budget or explicit unlimited policy, and null when the policy or attempt is unknown. Keep the unknown case separate:

function logConfiguredFinalAttempt(
    \DurableWorkflow\Worker\ActivityContext $context,
    \Psr\Log\LoggerInterface $logger,
): void {
    if ($context->retryInfo->isLastConfiguredAttempt() === true) {
        $logger->warning('Activity reached its final configured attempt', [
            'attempt' => $context->retryInfo->attemptNumber,
            'max_attempts' => $context->retryInfo->maxAttempts(),
        ]);
    }
}

A remaining budget does not promise another execution. Non-retryable failures, cancellation, workflow closure and elapsed deadlines can end execution earlier. Local activities and Server-free activity tests have no received Server retry snapshot and report an unknown policy.

workflowId and runId contain the received owning workflow instance and run IDs. Local activities retain the parent workflow task's IDs when available. Older claims and Server-free tests can leave either ID null. To signal the original owning run, use both IDs and an application event identity that stays stable across activity retries:

function signalOwningRun(
    \DurableWorkflow\Client $client,
    \DurableWorkflow\Worker\ActivityContext $context,
    string $orderId,
): array {
    if ($context->workflowId === null || $context->runId === null) {
        throw new \RuntimeException('Owning workflow IDs are unavailable; supply explicit IDs for this older claim.');
    }

    $eventIdentity = json_encode([$context->workflowId, $context->runId, $orderId], JSON_THROW_ON_ERROR);

    return $client->signalWorkflow(
        $context->workflowId,
        'order_activity_completed',
        [$orderId],
        runId: $context->runId,
        requestId: 'order-activity-completed:'.hash('sha256', $eventIdentity),
    );
}

The client must use the same runtime and namespace with permission to signal. Keep the request ID stable to identify retries of the same business event. The published PHP Server 2.11.3 can still record another signal with the same key. Signal receipt retention remains tracked in shared issue 216. Make the receiving handler idempotent for the business event and reconcile an ambiguous response before retrying. A stable key alone does not establish once-only signal admission or external effects. Do not substitute a task or attempt ID when an owning workflow identity is unavailable.

Use durable commands for workflow time

The context provides these replay-aware commands:

Command Use it for
activity() Retryable external work.
saga() Reverse-order compensation through ordinary activity history.
sleep() A durable timer that survives worker downtime.
childWorkflow() A separately identified durable execution.
startChildWorkflow() A started child whose identity the parent reads before awaiting its result.
all(), parallel() An ordered, fail-fast join over deferred activities, child workflows, and timers.
sideEffect() A small nondeterministic value recorded once in history.
getVersion(), patched(), deprecatePatch() A durable workflow-code evolution decision.
upsertSearchAttributes() Operator-visible indexed state.
continueAsNew() A fresh run with bounded history.

Do not use PHP's process sleep, random-number functions, current wall-clock time, or mutable global state directly to make workflow decisions. sideEffect() is for small values whose result belongs in history, not for network or database work.

Read the schedule fire that started a run

A run started by a schedule can read which fire started it. Use the occurrence time to choose the window to process, including for backfills, instead of the current time:

$origin = $context->scheduleOrigin();          // null when no schedule started the run
$windowEnd = $origin?->occurrenceTime;         // the tick or backfilled occurrence

The value comes from the run's history, so it is the same on every replay.

Declare search-attribute types

Pass a type map when the workflow must preserve search-attribute type identity across replay and redrive:

$context->upsertSearchAttributes(
    ['Stage' => 'approved', 'Attempts' => 2],
    ['Stage' => 'keyword', 'Attempts' => 'int'],
);

Declare every non-deleted attribute. Canonical types are string, keyword, keyword_list, int, float, bool, and datetime. Use null to delete a key. The runtime also checks declarations against the namespace's registered types. Search metadata uses the existing typed_search_attributes worker capability and protocol contract.

An explicitly typed call refuses a changed value or a changed recorded type. Malformed recorded type maps also refuse replay. Histories written before type metadata existed still compare values, since their type identity is unknown. Existing calls that omit the type map keep their value-only behavior. That legacy path does not establish typed replay equivalence; signal-wait redrive qualification is also separate.

Bound long histories

Use continueAsNew() for recurring or event-heavy workflows. Pass only the state needed by the next run:

$context->continueAsNew(
    arguments: [['cursor' => $nextCursor]],
    workflowType: 'reports.rollup',
    taskQueue: 'reports',
);

Clients can keep using ordinary WorkflowHandle methods across the run boundary. Use selected-run methods only when crossing that boundary would be a correctness bug.

Design check

If repeating a line after a process crash would be unsafe, that line belongs behind an idempotent activity or another durable command.