Skip to main content

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.

A deadline does not enforce itself

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