Signals
Signals let external code push data or decisions into a running workflow. Inside handle(),
awaitSignal() suspends the workflow until the named signal arrives, then returns its payload:
public function handle(): void
{
$decision = $this->awaitSignal('approval'); // suspends until delivered
if (($decision['approved'] ?? false) === true) {
$this->action(Publish::class)->run();
}
}
A signal delivered before the workflow awaits it is consumed inline without suspending.
Timeouts
The fluent form adds a deadline that turns an unanswered wait into a catchable exception:
use DiscoveryUkraine\SagaLaraFlow\Exceptions\AwaitSignalTimeoutException;
try {
$decision = $this->signal('approval')
->timeoutAfter(now()->addDay())
->wait();
} catch (AwaitSignalTimeoutException $e) {
$this->action(AutoReject::class)->run();
}
awaitSignal($name, $timeout) accepts the timeout as an optional second argument as well.
The package has no durable timers. A deadline is a value stored on the wait; something has to
notice that it passed. That something is the expiration sweep — either the scheduled
saga-flow:monitor command or the opt-in queue-looping listener. Until a sweep runs, the wait stays
open and AwaitSignalTimeoutException is never thrown, however long the deadline has been past.
If you set no deadline and no monitor.expiration.defaults.signal, the wait is unbounded by design.
See Expiration & monitoring for how to drive the sweep, and Testing for driving it from a test.
Delivering a signal
From anywhere in your app, deliver via the flow handle:
use DiscoveryUkraine\SagaLaraFlow\Facades\SagaFlow;
SagaFlow::loadFlow($runId)->signal('approval', ['approved' => true]);
A signal is accepted only by a run that can still consume one: Pending, Running or Waiting.
signal() throws CannotSignalTerminalFlowException on a finished run and
CannotSignalCancellingFlowException on one that is rolling back; both extend
CannotSignalFlowException, so a single catch covers every refusal. Nothing is written on a
refusal — no signal row, no event, no resume job. A run
pruned out from under the handle raises
FlowNotFoundException rather than writing a row that references nothing. Use the safe variant to
no-op instead:
$delivered = SagaFlow::loadFlow($runId)->signalIfRunning('approval', ['approved' => true]);
// false on a terminal run, one that is rolling back, and one that has been pruned
signalIfRunning() means "unless the run is past taking one" — it delivers to any run in
Pending, Running or Waiting, not only a Running one, and returns false for every reason
signal() would raise.
Neither may be called inside a DB::transaction() of your own: a rollback afterwards takes the
delivery with it, the wait stays open, and nothing tells you. See
what a host transaction leaves behind.
Finding the run to signal
Often you do not have the $runId on hand — you know the workflow and a tag. Query for it:
SagaFlow::query()
->whereWorkflow(ProvisionCompanyWorkflow::class)
->whereTag('company', $companyId)
->signalable() // Pending, Running, or Waiting — NOT running()
->handles()
->first()
?->signal('owner-synced');
Use signalable() (alias active()), not running(). It names the same three statuses
delivery accepts — Pending, Running, or Waiting — and a flow parked on awaitSignal() sits in
Waiting, not Running. Filtering by running() would silently miss exactly the run you are
trying to wake.
Reviving a failed step or child
A signal can also restart a step or a child that already failed, instead
of being awaited at a point in handle(). ->retryOnSignal('balance-refilled') on either parks the
run when it fails and runs it again when that signal is delivered — using the same delivery API as
everything above, or signalRetry(), which reads the name off the parked step or child. See
Retry on signal.
You can also deliver from the CLI — see Artisan commands:
php artisan saga-flow:signal {run} approval --payload='{"approved":true}'