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 RetryPolicy with no maximum_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 running
use 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.

Outside Temporal, startToClose is checked when 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 chain
use 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 new run belongs to the current execution and inherits it. Use withoutExecutionBound() to reuse a WorkflowTimeouts there. Its own copies are withTimeouts() and withTaskQueue(), which moves the next run to another task queue.


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-activites for payments-activities. A task queue fails silently; a namespace fails loudly with NOT_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 the CRON_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,
));

$client is the Temporal WorkflowClientInterface, and startAsync() exists only on Temporal, like the start options it takes. WorkflowClientInterface::startAsync() and startSync() declare the ?WorkflowStartOptions $options argument, so code typed against the interface can pass it. On every backend, a run starts with WorkflowResumeDispatcher::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 Int given a string is rejected before the round trip;
  • sixteen system attributes are read-only (RunId, WorkflowId, TaskQueue, StartTime, …). BuildIds, BinaryChecksums and TemporalChangeVersion are 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 Int

Recurring 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, namespace or taskQueue fails with UnsupportedByBackendException there.


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.


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')

The behaviour changes as well as the signatures. maxAttempts: 0 used 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 pass RetryLimit::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.