Signals
Signals allow you to trigger events in a workflow from outside the workflow. This can be useful for reacting to external events, enabling human-in-the-loop interventions, or for signaling the completion of an external task.
Named Signal Waits
A workflow calls await('signal-name') directly. The next accepted signal command with that name resumes the run and returns a deterministic value to the suspended workflow.
use Workflow\V2\Attributes\Signal;
use Workflow\V2\Attributes\Type;
use Workflow\V2\Workflow;
use function Workflow\V2\await;
#[Type('order-approval')]
#[Signal('approved-by', [
['name' => 'approvedBy', 'type' => 'string'],
])]
final class OrderApprovalWorkflow extends Workflow
{
public function handle(): array
{
$approvedBy = await('approved-by');
return [
'approved_by' => $approvedBy,
'workflow_id' => $this->workflowId(),
'run_id' => $this->runId(),
];
}
}
Trigger the signal from PHP by addressing the public instance id:
use Workflow\V2\WorkflowStub;
$workflow = WorkflowStub::load('order-123');
$result = $workflow->attemptSignalWithArguments('approved-by', [
'approvedBy' => 'Taylor',
]);
$result->accepted(); // true
$result->outcome(); // "signal_received"
$result->commandId(); // Durable signal-command id
$result->instanceId(); // "order-123"
Signal behavior:
- each external signal name should be declared up front with a repeatable
#[Signal('signal-name')]class attribute - a signal declaration may also include an optional ordered parameter contract, for example
#[Signal('approved-by', [['name' => 'approvedBy', 'type' => 'string']])] - signal commands target the public workflow instance id, not a run id
- instance-targeted signal intake resolves the newest durable run for that instance, so continue-as-new chains keep the same public signal route even if the mutable current-run pointer drifts
- an accepted or rejected signal records a durable command row, and new runs also get one first-class
workflow_signal_recordslifecycle row linked back to that command - the run's declared signal and update contracts are snapped into the typed
WorkflowStartedpayload asdeclared_signals,declared_signal_contracts,declared_updates, anddeclared_update_contracts - each named wait gets its own durable
signal_wait_id, and buffered same-name signals keep that same id acrossSignalReceived,SignalWaitOpened, andSignalApplied - selected-run detail exposes those lifecycle rows as
signals[*], withid,command_id,command_sequence,workflow_sequence,signal_wait_id, status, outcome, validation errors, and stored arguments - when a declared signal contract exists, PHP, webhook, and Waterline signal intake all accept either positional arguments or a JSON or associative object of named arguments, and invalid requests reject as
rejected_invalid_argumentswith machine-readablevalidation_errorsfor missing arguments, unknown arguments, declared type mismatches, or nullability violations - when a signal has no declared parameter contract,
await('approved-by')receivestruewhen no arguments were sent, the single argument when one value was sent, or the full argument array when multiple values were sent;attemptSignalWithArguments('name', ['key' => 'value'])keeps treating that associative array as one payload value instead of guessing named arguments - before a signal is accepted, Waterline projects the run as
wait_kind = signalandliveness_state = waiting_for_signal, which means it is waiting healthily for external input rather than needing repair - once a signal command is durably accepted, that external signal wait is resolved; Waterline should then show either the backing workflow task with
workflow_wait_kind = signal,workflow_signal_id,workflow_command_id, the open wait id, and theworkflow_signalresume source, or, if that task disappeared beforeSignalApplied,repair_neededwithwait_kind = signal,open_wait_id = signal-application:{signal_id}when the lifecycle row exists, and the same metadata on the repaired task - repeated waits with the same signal name stay distinguishable in Waterline and the typed timeline through both the workflow step
sequenceand the durablesignal_wait_id - resolved signal waits also keep the accepted command's snapped
command_sequence, status, outcome, and signal lifecycle id in typed history, so operator detail stays stable even if the mutable command row later drifts - if several same-name signal commands are accepted before the workflow reaches the later wait, durable command sequence remains the source of truth for ordering, while the accepted command's
signal_wait_idstays stable even if that later wait opens afterward attemptRepair()on that healthy signal wait returnsrepair_not_needed, because the durable satisfier is already the named signal itself- unknown signal names reject as
rejected_unknown_signalwithrejection_reason = unknown_signal - rejected signal commands otherwise return
rejected_not_startedwhen the instance has no current run,rejected_not_activewhen the current run is already closed, andrejected_invalid_argumentswithrejection_reason = invalid_signal_argumentswhen the durable signal contract rejects the payload before the command is accepted, including declared type or nullability mismatches
Message Stream Cursor
Signals and updates are durably ordered within an instance-level message stream. Every accepted signal or update command receives a monotonically increasing message_sequence from the instance, independent of the per-run command_sequence. Each run tracks a message_cursor_position that records how far through the instance message stream it has consumed.
Key properties:
- Instance-scoped ordering:
message_sequenceprovides a total order across all signals and updates for the instance's entire lifetime, not just one run. - Durable cursor: Each run's
message_cursor_positionis persisted in the database, not held in-memory. History events of typeMessageCursorAdvancedrecord every cursor advancement with the stream key, previous position, and new position. - Continue-as-new handoff: When a workflow continues as new, the new run inherits the closing run's
message_cursor_position. This means the continued run knows exactly which messages were already consumed and will not reprocess them. - Idempotent advancement: Advancing the cursor to the same or a prior position is a no-op, making replay safe.
The cursor is automatically managed by the engine during signal application and update application. Workflow authors do not need to interact with it directly.
Condition Waits
await($condition, $conditionKey = null) provides replay-safe condition waits. Use it when the predicate depends only on workflow state that was already derived from durable inputs such as updates, activity results, or child results. If you want one named external signal value directly, call await('name').
Condition waits are driven by an update method instead of signal mutators:
use Workflow\UpdateMethod;
use function Workflow\V2\await;
use Workflow\V2\Attributes\Type;
use Workflow\V2\Workflow;
#[Type('approval-workflow')]
class MyWorkflow extends Workflow
{
private bool $ready = false;
public function handle(): void
{
await(fn () => $this->ready, 'approval.ready');
}
#[UpdateMethod]
public function setReady(bool $ready = true): array
{
$this->ready = $ready;
return ['ready' => $this->ready];
}
}
The optional condition key is a stable, URL-safe operator label for the wait. Waterline exposes it as condition_key and target_name, and replay validates the previously recorded key before resolving the wait so a later deployment cannot accidentally reuse the same workflow step for a different predicate. Adding a key to a wait that was already recorded without one is also a compatibility change; old unkeyed history reports the recorded key as none and blocks replay until a compatible build is deployed. Replay also blocks when that workflow sequence already recorded a different typed step shape, such as a pure timer, because condition-wait history cannot be appended over an incompatible committed event. If a worker sees a recorded key, predicate fingerprint, or step shape that no longer matches the current wait, the run stays open with liveness_state = workflow_replay_blocked and the workflow task is marked replay_blocked; repair retries that task after the compatible build is back.
Important: The await() function should only be used in a workflow, not an activity.