Tags & querying
Tagging runs
Attach searchable key/value tags at creation, declaratively, from the parent that starts a child,
from inside the workflow, or from outside through a FlowHandle:
SagaFlow::create(CheckoutWorkflow::class)
->withTags(['tenant' => 'acme', 'channel' => 'web'])
->run();
// declaratively on the workflow class (repeatable)
#[Tag('orders')]
#[Tag('team', 'checkout')]
class CheckoutWorkflow extends Workflow { /* ... */ }
// on a child, from the parent that starts it
$this->child(ShipmentWorkflow::class, ['order-42'])
->withTags(['customer' => $customerId])
->run();
// from inside handle()
$this->tag('priority', 'high');
// or several at once
$this->tags([
'priority' => 'high',
'attempt' => 2, // int values are cast to string
'orders' => null, // a tag with no value
]);
// from outside, on a loaded handle
SagaFlow::loadFlow($runId)
->tag('payment-failed')
->withTags(['attempt' => 2, 'orders' => null]);
Explicit tags passed to withTags() override attribute tags with the same key. On a handle,
tags() reads and withTags() writes. A child run started with child() gets the #[Tag]s of
its own class and the tags the parent passes to withTags() on the child builder, written together
with the run; it does not get its parent's tags.
Re-tagging an existing key overwrites its value rather than adding a second tag — the database
enforces one row per (flow_run_id, key). Both tag() and tags() / withTags() are idempotent
across replays — safe to call unconditionally at the top of handle().
Tags are not history: they carry no sequence and are never consulted during replay. A workflow
calling $this->tag('x', ...) in handle() re-runs that write on every replay, overwriting
whatever a host, or a parent through child()->withTags(), set under the same key. Keys written
from outside or by a parent should not collide with keys the workflow writes itself.
Querying runs
SagaFlow::query() returns a fluent, type-safe FlowQuery over flow runs:
use DiscoveryUkraine\SagaLaraFlow\Enums\FlowStatus;
$stuck = SagaFlow::query()
->whereWorkflow(CheckoutWorkflow::class)
->whereTag('tenant', 'acme')
->waiting()
->before(now()->subHour())
->get(); // Collection<FlowRun>
Filters
whereTag(string $key, string|int|null $value = null)— a null$valuematches any value.whereTagIn(string $key, array $values)— runs whose tag holds any of these values. Called with an empty array, it matches no run. Tag values are stored as strings, and both methods compare anintas the string it was written as:7findstag('customer', 7), not'007'.whereStatus(FlowStatus ...$statuses)and shortcutsrunning(),waiting(),completed(),failed()active()(aliassignalable()) — runs that can still receive a signal:Pending,Running, orWaiting. Use this to find a run to deliver a signal to: a flow parked onawaitSignal()isWaiting, notRunning, sorunning()would miss it.whereWorkflow(string $workflowClass)whereId(string ...$ids)— runs with one of these ids. Called with none, it matches no run, so an empty selection stays empty.whereAwaitingSignal(?string $name = null)— runs whose wait for a signal is still open, whichever seam opened it (awaitSignal()orretryOnSignal()). A null$namematches any.whereAwaitingRetrySignal(?string $signal = null)— runs holding a step or a child parked byretryOnSignal(), i.e. anaction_runsor aflow_childrenrow inawaiting_retrywhose wait is still open or already holds a delivery. A null$signalmatches any.before(DateTimeInterface)/after(DateTimeInterface)(both filtercreated_at)
// everything blocked on this name, planned waits included
SagaFlow::query()->whereAwaitingSignal('approval')->get();
// only steps and children that failed and parked
SagaFlow::query()->whereAwaitingRetrySignal('balance-refilled')->handles();
// the runs of a few picked customers
SagaFlow::query()->whereTagIn('customer', $customerIds)->get();
// the parked runs an operator picked, whatever signal each one waits on
SagaFlow::query()->whereAwaitingRetrySignal()->whereId(...$runIds)->handles();
Waits and parked retries
Both seams park the run as Waiting and open a signal row, so flow_signals alone cannot tell them
apart. What separates them lives on action_runs, or on flow_children for a child:
awaitSignal('approval') | retryOnSignal('balance-refilled') | |
|---|---|---|
flow_runs.status | waiting | waiting |
row in flow_signals | approval / waiting | balance-refilled / waiting |
| row at that wait | none | awaiting_retry in action_runs or flow_children, retry_signal set |
| failure snapshot for operators | none | exception: {class, message, code}, on the step or the child's run |
The two also stop matching at different moments. Delivery marks the wait Received while the parked
row keeps its awaiting_retry status until replay resumes the run. In that gap only
whereAwaitingRetrySignal() matches — which is what makes it the filter that finds a run whose
signal arrived but whose resume never did. A timeout marks the wait TimedOut, and from then on
neither matches: the next replay gives the retry up, and no signal can change that.
Both read the rows a run holds rather than the run's own status. A run that finishes settles its open
wait and its parked step alike (see statuses), so it drops out of both filters on its
own. Rows left behind by runs that finished before this behaviour existed still carry waiting and
awaiting_retry, and their runs still match — compose with signalable() while any of those remain.
Terminals
get(): Collection<FlowRun>first(): ?FlowRuncount(): intpaginate(int $perPage = 15): LengthAwarePaginatorhandles(): Collection<FlowHandle>— hydrate matched runs as operable handlesbuilder(): Builder<FlowRun>— escape hatch to the raw Eloquent builder for ordering/limits
$handles = SagaFlow::query()->running()->handles();
$page = SagaFlow::query()->failed()->paginate(25);
$latest = SagaFlow::query()->builder()->latest()->limit(10)->get();