Actions
action(string $actionClass, mixed ...$arguments) returns an ActionBuilder; run() executes the
action and returns its result. Arguments passed to action() are forwarded to the action's
handle() after its injected dependencies.
$tenantId = $this->action(CreateTenant::class, $email)->run();
Retries and timeouts
Retries and per-attempt timeouts use native Laravel queue semantics — declare them as public properties on the action:
use DiscoveryUkraine\SagaLaraFlow\Action;
class ChargeCard extends Action
{
public int $tries = 3; // up to 3 attempts when queued
public int $timeout = 30; // seconds per attempt (0 = none)
public function handle(PaymentGateway $gateway, string $orderId): string
{
return $gateway->charge($orderId);
}
}
You can also set these declaratively with #[ActionTimeout(seconds: 30)] (and name a step with
#[ActionName('charge-card')]).
Per-step deadline
Independent of the queue timeout, a step can carry a deadline after which it expires:
$this->action(ChargeCard::class, $orderId)
->expiresAt(now()->addMinutes(2))
->run();
There is no timeoutAfter() on an action builder — that method belongs to
signals. Action deadlines use expiresAt().
Handling failure
run() throws when the action ultimately fails (after exhausting $tries), so the workflow can
react instead of dying:
use DiscoveryUkraine\SagaLaraFlow\Exceptions\ActionFailedException;
use DiscoveryUkraine\SagaLaraFlow\Exceptions\FlowExpiredException;
try {
$this->action(ChargeCard::class, $orderId)->run();
} catch (ActionFailedException $e) {
// retries exhausted
} catch (FlowExpiredException $e) {
// the step or run passed its deadline
}
When (and where) it throws
The failure does not surface at the moment the action fails — it surfaces on the next replay:
- Queued mode. The action runs in its own
RunActionJob, off thehandle()stack, and retries per its$tries. Only once it has ultimately failed does the engine re-drivehandle()from the top; the recorded-Failedstep then replays as a throw, and that is the moment yourtry/catcharound->run()catchesActionFailedException. So atry/catchinhandle()genuinely does catch the failure — just on the replay pass, which is the whole point of deterministic replay. - Sync mode. The step runs inline and
run()re-throws the action's raw exception (notActionFailedException). Catch the concrete exception type your action can throw, or the baseDiscoveryUkraine\SagaLaraFlow\Exceptions\FlowExceptionplus your own types.
try/catch around ->run() is for local decisions — "if ChargeCard fails, try PayPal
instead". For a cross-cutting "report whenever any workflow fails", listen to the
FlowFailed event instead: it fires once on the terminal transition, on both the
direct-fail and the fail-after-compensation paths, independent of sync/queued. If you do report
from inside handle(), re-throw so the engine still fails and compensates the run — swallowing
the exception lets handle() continue past a step that produced no result.
Do not catch DiscoveryUkraine\SagaLaraFlow\Exceptions\Internal\FlowSuspended (or any
InternalFlowControl) — those are the engine's suspend/replay signals, not errors. Business
exceptions (ActionFailedException, FlowExpiredException, ChildWorkflowFailedException, …) all
extend FlowException and are safe to catch; the two internal signals are the only things under
…\Exceptions\Internal\. If you use a broad catch (\Throwable $e), re-throw control flow first:
} catch (\Throwable $e) {
if ($this->isFlowControl($e)) {
throw $e;
}
// handle real errors here
}
To make a failing action not fail the flow, see Optional actions. To undo completed work when a later step fails, see Sagas & compensations.