Options and value objects#
Scheduling options (retry limits, timeouts, task queues, cron schedules, search attributes) are value objects instead of primitives. Each one validates what it can at construction. A mistake therefore surfaces on the line where you wrote it, instead of appearing later as a server rejection, a silently rewritten value, or an execution (one durable run of a workflow; see the glossary) that waits forever.
Every rule enforced here was probed against a running Temporal server before being written. Where the server is permissive, these objects usually are too; where they are stricter, the docblock gives the reason.
Duration#
A length of time. Replaces the ?float …Seconds fields.
use Gplanchat\Durable\Duration;
Duration::seconds(30);
Duration::milliseconds(250);
Duration::minutes(2.5);
Duration::hours(1);
Duration::zero(); // no wait
Duration::infinity(); // no bound at all: the default deadline of await()
infinity() is an ordinary value. It compares (shortest(), isLongerThan()), travels
through configuration, and spares code that computes a deadline a special case for “no bound”.
It cannot be sent over the wire: timer() rejects it, because a timer that never fires is a
command in history for a wake-up that never happens.
It also accepts native and Carbon values, without depending on Carbon:
Duration::of(new DateInterval('PT90S')); // CarbonInterval extends DateInterval
Duration::of(CarbonInterval::minutes(5));
Duration::until($deadline); // Carbon implements DateTimeInterface
Duration::until($deadline, from: $from);
Duration::from($anything); // Duration|DateInterval|DateTimeInterface|int|float
$duration->toDateInterval();of() takes a length, until() takes an instant. A DateTimeInterface only becomes a
duration once measured against another instant, hence the two separate methods.
A negative duration is rejected, and so is a computed INF or NAN: an infinite duration is
created by name, with infinity(). Calendar units (years, months) have no fixed length and are
resolved against a fixed UTC anchor, so prefer days, hours and minutes for a bound.
RetryLimit#
How many times an activity may run. An activity is a unit of side effect in a workflow, such as an HTTP call, a database write or an e-mail; see the glossary.
use Gplanchat\Durable\Activity\RetryLimit;
RetryLimit::unlimited(); // no bound on the number of attempts (the default)
RetryLimit::ofAttempts(3); // three attempts in total
RetryLimit::ofRetries(2); // two retries, so three attempts
RetryLimit::once(); // any failure is final
Unlimited is the default, matching a Temporal
RetryPolicywith nomaximum_attempts. An activity that always fails and does not bound its attempts does not fail the workflow; it retries forever. Only a non-retryable exception, a timeout, or cancellation stops it.Pass
RetryLimit::once()when you want a failure to be final.
ofAttempts(0) is rejected; an unbounded limit is written unlimited(). ofRetries(0) means
“no ceiling”, the meaning that setting has always had in the bundle configuration.
ActivityTimeouts#
The four bounds of an activity, taken together, because each bounds a different segment of its life:
scheduled ──schedule-to-start──▶ started ──start-to-close──▶ finished
└─────────────────── schedule-to-close ─────────────────────────┘
heartbeat: longest silence tolerated while runninguse Gplanchat\Durable\Activity\ActivityTimeouts;
use Gplanchat\Durable\Duration;
ActivityTimeouts::none(); // the backend decides
ActivityTimeouts::attempt(Duration::seconds(30)); // the common case: bound one attempt
(new ActivityTimeouts(
scheduleToStart: Duration::seconds(10),
startToClose: Duration::minutes(5),
scheduleToClose: Duration::minutes(30),
heartbeat: Duration::seconds(30),
));
// Each bound has a with…() that returns a copy with that one bound changed; null removes it.
ActivityTimeouts::attempt(Duration::seconds(30))
->withScheduleToStart(Duration::seconds(10))
->withHeartbeat(Duration::seconds(5));A heartbeat longer than startToClose is rejected: the attempt would end before the first missed
heartbeat, so the heartbeat bound would never apply.
The heartbeat bound is Temporal only. A journal backend (InMemory, DBAL, Illuminate, Magento
Database) throws UnsupportedByBackendException when you schedule an activity with a heartbeat bound.
An activity without startToClose is bounded at 30 seconds per attempt on every backend, as on
Temporal; scheduleToClose, when set, replaces that default. Outside Temporal, the check runs after
the attempt returns; nothing enforces it while the attempt runs. An attempt that overran fails with
a timeout, its result is discarded, and the retry policy determines what comes next. Nothing
interrupts an attempt that never returns. Stopping a hung worker is the job of whatever supervises
the process. messenger:consume --time-limit only checks between two messages, so it cannot stop
one.
scheduleToClose is the budget for the whole activity, retries included. On Temporal and on the
journal backends, a failed attempt whose backoff would end past it is not retried: the activity
fails with that attempt’s own failure and the Timeout retry state. On the journal backends, the
bound is also checked when a worker takes a message, and fails with “Activity schedule-to-close
timeout exceeded.” scheduleToStart applies to a message waiting in the queue. On Temporal, it
applies to every attempt. On the journal backends, it applies to the first attempt only, and a
retry waiting for its delay is not bounded by it, only by scheduleToClose.
Temporal requires a closing bound. When none is set, the bridge supplies a default. That fallback
has its own name, executionBoundOr().
Putting activity options together#
use Gplanchat\Durable\Activity\ActivityOptions;
// 3 attempts, 30s each, 1s before the first retry.
$options = ActivityOptions::of(3, 30, 1, [PaymentRefusedException::class], 'payments');of() takes its arguments in the order you think in: how many attempts, and how long each one
may take. It accepts scalar equivalents: an int is a number of attempts, a bare duration
is the startToClose bound of one attempt, and a float is seconds. of(0) is rejected; it is
not read as “unlimited”. The long form stays available and is strictly equivalent, for when you
want every intent named:
use Gplanchat\Durable\Activity\{ActivityOptions, ActivityTimeouts, RetryLimit};
use Gplanchat\Durable\{Duration, TaskQueue};
$options = new ActivityOptions(
RetryLimit::ofAttempts(3),
initialInterval: Duration::seconds(1),
nonRetryableExceptions: [PaymentRefusedException::class],
taskQueue: TaskQueue::named('payments'), // Temporal only; on a journal backend, this option throws
timeouts: ActivityTimeouts::attempt(Duration::seconds(30)),
);
// Options are carried by the stub: every call it makes uses them.
$orders = $this->environment->activityStub(OrderActivities::class, $options);
$result = $this->environment->await($orders->charge($orderId));The retry interval grows by backoffCoefficient and is capped. With no explicit cap, Temporal’s
default applies: 100 × the initial interval. With unlimited attempts, this cap keeps an
exponential backoff from diverging.
WorkflowTimeouts#
The three workflow bounds, which nest:
execution ─┬─ run 1 ─┬─ run 2 (continue-as-new, retry) ─ …
│ └─ task: one worker decision round-trip
└────────────── execution: the whole chainuse Gplanchat\Durable\{Duration, WorkflowTimeouts};
WorkflowTimeouts::none();
WorkflowTimeouts::run(Duration::minutes(10));
new WorkflowTimeouts(
execution: Duration::hours(1),
run: Duration::minutes(10),
task: Duration::seconds(10),
);
// The same with…() copies: withExecution(), withRun(), withTask(); null removes the bound.
WorkflowTimeouts::run(Duration::minutes(10))->withTask(Duration::seconds(10));A run bound longer than the execution bound is rejected at construction. The server accepts it and silently lowers the run bound to the execution bound, so the configuration you wrote is not the one that applies.
ContinueAsNewOptions rejects any execution bound: the next run continues the current
execution’s chain and inherits its bound. Use withoutExecutionBound() to reuse a WorkflowTimeouts there.
Its own copies are withTimeouts() and withTaskQueue(), which moves the next run to another
task queue.
Which bounds a child workflow gets on the journal backends#
On the in-memory, DBAL, Illuminate and Magento Database backends, a child started with
ChildWorkflowOptions::$timeouts gets these bounds. Temporal applies all three.
| Bound | Journal backends |
|---|---|
execution |
Applied to a child that the engine runs inline (the in-memory runner, WorkflowTestEnvironment): a child whose next timer falls after the deadline fails its parent’s await with DurableChildWorkflowFailedException (“Child workflow <id> timed out.”). The deadline is checked when the child’s clock jumps to a timer: time spent in activities and in an inline grandchild is not counted. Recorded as a ChildWorkflowDeadline event, not yet applied, for a child that runs on a worker (issue #1069). |
run |
Recorded, not applied. It differs from execution only across continue-as-new, where the deadline must restart with each run. |
task |
Recorded, not applied. It bounds a worker round trip, which the journal does not have. |
The execution id differs between backends. On Temporal, the next run keeps the Durable execution
id. On the journal backends, the journal generates a new ExecutionId for it. See
the execution id after continueAsNew.
TaskQueue and WorkflowNamespace#
use Gplanchat\Durable\{TaskQueue, WorkflowNamespace};
TaskQueue::named('payments-activities');
WorkflowNamespace::named('billing');Both reject a blank name, leading or trailing whitespace, and control characters. The server accepts all three, but they are never intentional. For a task queue the consequence is silent: work is queued to a name no worker polls, and the execution waits, with nothing in the logs.
Neither object catches a typo that is still a valid name, such as
payments-activitesforpayments-activities. A task queue fails silently; a namespace fails loudly withNOT_FOUND. Catching the former would need a registry of the queues actually served.
Namespace comparison is case-sensitive, as on the server: Billing and billing are two
different namespaces.
CronSchedule#
A recurrence, validated at construction. Without this validation, a typo only appears when the server rejects the first start.
use Gplanchat\Durable\{CronSchedule, Duration};
CronSchedule::parse('0 9 * * 1-5');
CronSchedule::daily(); // also hourly, weekly, monthly, yearly
CronSchedule::every(Duration::minutes(90)); // @every 1h30m
CronSchedule::dailyAt(9, 30); // 30 9 * * *
CronSchedule::dailyAt(9)->inTimeZone('Europe/Paris');Without a time zone the server reads the expression in UTC, which is rarely what “every day at 9” means.
inTimeZone()emits theCRON_TZ=prefix the server reads.
Validation mirrors the server’s, expression by expression. It checks field count, characters,
ranges, and reachability: 0 0 31 4 * is rejected because April has thirty days. Day of week
runs 0 to 6, so 7 for Sunday is rejected. ? is accepted anywhere as a synonym for *.
The two most likely mistakes are named in the error: a six-field expression (a Quartz cron copied from elsewhere) and a schedule with no occurrence.
See Recurring workflows below for starting one.
SearchAttributes#
What an execution can be found by.
use Gplanchat\Durable\{ExecutionId, SearchAttributes, WorkflowStartOptions};
$attributes = SearchAttributes::none()
->keyword('OrderId', 'ORD-4242')
->int('Amount', 4242)
->bool('Priority', true)
->double('Ratio', 0.75)
->text('Note', 'gift wrapping')
->datetime('DueAt', new DateTimeImmutable('2026-01-01'))
->keywordList('Tags', ['gift', 'express']);
$client->startAsync('CheckoutWorkflow', $input, ExecutionId::fromString($executionId), new WorkflowStartOptions(
searchAttributes: $attributes,
));
$clientis the TemporalWorkflowClientInterface, andstartAsync()exists only on Temporal, like the start options it takes.WorkflowClientInterface::startAsync()andstartSync()declare the?WorkflowStartOptions $optionsargument, so code typed against the interface can pass it. On every backend, a run starts withWorkflowResumeDispatcher::dispatchNewWorkflowRun()(Getting started), which takes no start options.
The object is immutable: each call returns a new instance.
Two of the three server rules are checked locally:
- the value must match the type: an
Intgiven a string is rejected before the round trip; - sixteen system attributes are read-only (
RunId,WorkflowId,TaskQueue,StartTime, …).BuildIds,BinaryChecksumsandTemporalChangeVersionare not among them and can be written.
The third cannot be checked locally: the attribute must be registered in the namespace, and
checking it would mean reading the namespace registry. The server answers
has no mapping defined for search attribute.
temporal operator search-attribute create --name OrderId --type Keyword
temporal operator search-attribute create --name Amount --type IntRecurring workflows#
use Gplanchat\Durable\{CronSchedule, ExecutionId, WorkflowStartOptions};
$client->startCron('NightlyReconciliation', $input, $executionId, CronSchedule::dailyAt(2));
// or through the options object, alongside timeouts and search attributes
$client->startAsync('NightlyReconciliation', $input, ExecutionId::fromString($executionId), new WorkflowStartOptions(
cronSchedule: CronSchedule::dailyAt(2)->inTimeZone('Europe/Paris'),
));A Temporal cron is the same logical execution, relaunched by the server with a fresh history at each due time; no external scheduler is involved. The next run does not start until the previous one has finished, and a missed occurrence is skipped, never caught up.
Child workflows accept the same schedule through ChildWorkflowOptions.
Cron is a Temporal capability. The journal backends (in-memory, DBAL, Illuminate, Magento Database) have no scheduler: a child workflow’s
cronSchedule,namespaceortaskQueuefails withUnsupportedByBackendExceptionthere.
Re-using a child’s workflow id#
ChildWorkflowOptions::$workflowIdReusePolicy sets what happens when a child starts under an
id that an earlier run already used. A policy applies to a finished run only: starting a child under
an id whose run is still running throws ChildWorkflowIdInUseException under every policy.
| Earlier run under the id | AllowDuplicate |
AllowDuplicateFailedOnly |
RejectDuplicate |
|---|---|---|---|
| Still running | ChildWorkflowIdInUseException |
ChildWorkflowIdInUseException |
ChildWorkflowIdInUseException |
| Completed successfully | A new run starts | ChildWorkflowIdCompletedException |
ChildWorkflowIdAlreadyUsedException |
| Failed, cancelled or terminated | A new run starts | A new run starts | ChildWorkflowIdAlreadyUsedException |
Each exception carries the id in its childExecutionId property and extends
\InvalidArgumentException. On the journal backends (in-memory, DBAL, Illuminate, Magento
Database) the call to the stub’s method throws. On Temporal, a parent that
re-uses an id inside its own history gets the same exceptions at the call, because the same
execution context reads that history. An id that only an earlier parent used fails differently:
the start fails on the server, and the parent gets DurableChildWorkflowFailedException when it
awaits the child. The journal backends check the child’s own stream across all parents, so they
throw the named exceptions at the call in that case too.
A child’s memo, summary and details#
ChildWorkflowOptions takes a memo, a one-line summary and longer details for the child.
use Gplanchat\Durable\ChildWorkflowOptions;
$shipment = $env->childWorkflowStub(ShipmentWorkflow::class, new ChildWorkflowOptions(
memo: ['orderId' => 'ORD-4242'],
staticSummary: 'Ship order ORD-4242',
staticDetails: 'Two parcels, carrier chosen at dispatch',
));The SQL and in-memory backends record all three in the parent’s journal, on the event that schedules the child. On Temporal, the memo becomes the child’s memo, and the summary and details become the user metadata of the start command, which the Temporal UI shows on the child.
The summary and details need Temporal Server 1.25 or later. An older server drops them without an error, and the child starts without them. The memo reaches every supported server.
Durable reserves the memo keys durableExecutionId and durableWaitingOn, and writes them in a
child’s memo itself on Temporal. On every backend, new ChildWorkflowOptions() throws
UnsupportedByBackendException when the memo uses one of them. A run that builds such options
fails at that line, during replay too (the workflow code running again from its first line to
resume; see the glossary). Replay compares a child’s type and input with the
journal, not its memo, so a run in flight resumes once the code uses another key.
Behaviour that differs between backends#
Two accepted differences touch the options of this page. The execution id after continueAsNew is
covered above. A run whose code no longer matches its journal is parked on Temporal and ends failed
on the journal backends; see the backends page.
Migrating from the previous API#
Named arguments changed with the value objects. A call that was not migrated fails immediately, with an error.
| Before | Now |
|---|---|
maxAttempts: 3 |
RetryLimit::ofAttempts(3) as the first argument |
maxAttempts: 1 |
RetryLimit::once() |
maxAttempts: 0 |
RetryLimit::unlimited(), and it is the default |
->withMaxAttempts(3) |
->withRetryLimit(RetryLimit::ofAttempts(3)) |
initialIntervalSeconds: 1.0 |
initialInterval: Duration::seconds(1) |
maximumIntervalSeconds: 60.0 |
maximumInterval: Duration::seconds(60) |
startToCloseTimeoutSeconds: 30.0 |
timeouts: ActivityTimeouts::attempt(Duration::seconds(30)) |
scheduleToStartTimeoutSeconds, scheduleToCloseTimeoutSeconds, heartbeatTimeoutSeconds |
named arguments of ActivityTimeouts |
workflowRunTimeoutSeconds: 600.0 |
timeouts: WorkflowTimeouts::run(Duration::minutes(10)) |
workflowExecutionTimeoutSeconds, workflowTaskTimeoutSeconds |
named arguments of WorkflowTimeouts |
taskQueue: 'payments' |
taskQueue: TaskQueue::named('payments') (on activity options: Temporal only; on a journal backend, this option throws) |
namespace: 'billing' |
namespace: WorkflowNamespace::named('billing') |
cronSchedule: '0 9 * * *' |
cronSchedule: CronSchedule::parse('0 9 * * *') |
searchAttributes: ['OrderId' => 'x'] |
SearchAttributes::none()->keyword('OrderId', 'x') |
cancellationType: ActivityCancellationType::Abandon |
removed: no backend reads it, so delete the argument (Rector does it) |
The behaviour changes as well as the signatures.
maxAttempts: 0used to mean no retry and now means unlimited, matching Temporal. An activity that always fails and does not bound its attempts no longer fails the workflow. Review any activity that relied on the old default and passRetryLimit::once()where a failure should be final.
Twelve with*() methods on ActivityOptions that nothing called were removed. withRetryLimit()
and withTimeouts() remain.
Search attributes had a second, less visible problem: they were written to the journal (the record of everything an execution decided and received) and never sent to the server. They now reach it, so an attribute that was silently dropped may now be rejected as unregistered. Register it, or remove it.