Download the PHP package discovery-ukraine/saga-lara-flow without Composer

On this page you can find all versions of the php package discovery-ukraine/saga-lara-flow. It is possible to download/install these versions without Composer. Possible dependencies are resolved automatically.

FAQ

After the download, you have to make one include require_once('vendor/autoload.php');. After that you have to import the classes with use statements.

Example:
If you use only one package a project is not needed. But if you use more then one package, without a project it is not possible to import the classes with use statements.

In general, it is recommended to use always a project to download your libraries. In an application normally there is more than one library needed.
Some PHP packages are not free to download and because of that hosted in private repositories. In this case some credentials are needed to access such packages. Please use the auth.json textarea to insert credentials, if a package is coming from a private repository. You can look here for more information.

  • Some hosting areas are not accessible by a terminal or SSH. Then it is not possible to use Composer.
  • To use Composer is sometimes complicated. Especially for beginners.
  • Composer needs much resources. Sometimes they are not available on a simple webspace.
  • If you are using private repositories you don't need to share your credentials. You can set up everything on our site and then you provide a simple download link to your team member.
  • Simplify your Composer build process. Use our own command line tool to download the vendor folder as binary. This makes your build process faster and you don't need to expose your credentials for private repositories.
Please rate this library. Is it a good library?

Informations about the package saga-lara-flow

[![Latest Version on Packagist](https://img.shields.io/packagist/v/discovery-ukraine/saga-lara-flow.svg?style=flat-square)](https://packagist.org/packages/discovery-ukraine/saga-lara-flow) [![Tests](https://img.shields.io/github/actions/workflow/status/discovery-ukraine/saga-lara-flow/run-tests.yml?branch=main&label=tests&style=flat-square)](https://github.com/discovery-ukraine/saga-lara-flow/actions/workflows/run-tests.yml) [![PHPStan](https://img.shields.io/github/actions/workflow/status/discovery-ukraine/saga-lara-flow/phpstan.yml?branch=main&label=phpstan&style=flat-square)](https://github.com/discovery-ukraine/saga-lara-flow/actions/workflows/phpstan.yml)

Saga Lara Flow is a workflow management engine with an integrated Saga pattern, built on top of Laravel Queues.

It lets you write a long-running, durable business process as a single deterministic PHP method: each step runs, is recorded, and survives worker restarts through exception-based suspension and replay. When a step fails partway through, registered compensations roll back the completed work in reverse order.

It is inspired by Durable Workflow (formerly Laravel Workflow), but it is not a replacement for it. Saga Lara Flow positions itself as a lighter, native-Laravel alternative: no Fibers, generators, or promises — just queues, an event log, and Eloquent.

📚 Full documentation: sagalaraflow.dev

If ReserveStock or ShipOrder fails, RefundCard (and any other registered compensation) runs automatically, in reverse order.

Table of contents

Installation

Install the package via Composer:

Run the migrations:

The engine's migrations ship with the package, so migrate picks them up directly — no publish step. Future versions add their migrations the same way: composer update then php artisan migrate.

Always migrate after an upgrade. The engine writes its newest columns for every action it schedules, so upgrading without php artisan migrate breaks ordinary workflow execution with an unknown-column error — not just the newest feature. See UPGRADING.md.

Optionally publish the config file:

Customize the schema through config — database.table_prefix, database.connection, and the swappable models.* — rather than by editing the migration. (The migration runs automatically; do not also vendor:publish it, or migrate would try to run both the published copy and the package's own.)

Requirements: PHP ^8.5, Laravel 13 (illuminate/*: ^13). The package registers its service provider and the SagaFlow facade automatically.

Configuration

Every setting lives in config/saga-lara-flow.php. The most common ones:

Your first workflow

Generate a workflow and an action:

A workflow is a class extending Workflow with a deterministic handle(). A workflow author never news an action — they schedule it through the DSL, and the engine runs, records, and replays it for you:

An action is a unit of work with native Laravel dependency injection:

Start it:

Actions

action(string $actionClass, mixed ...$arguments) returns an ActionBuilder; run() executes it and returns the action's result. Arguments passed here are forwarded to the action's handle() after its injected dependencies.

Retries and timeouts use native Laravel queue semantics — declare them on the action:

A per-step deadline (independent of the queue timeout) is set on the builder:

run() throws when the action ultimately fails, so a workflow can react to it:

Two things about when this throws:

⚠️ Never 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 live under …\Exceptions\Internal\ and are the only things a broad catch (\Throwable $e) must re-throw: if ($this->isFlowControl($e)) { throw $e; }.

Sagas & compensations

The Saga pattern trades distributed transactions for compensating actions: each step registers how to undo itself, and on failure the engine rolls completed steps back in reverse order.

Action-level compensation (the primary style) attaches an undo to each step:

Compensation can also be a closure:

Grouped sagas via saga() express a compensation boundary explicitly and give you group-level policies:

CompensationFailurePolicy::Stop (default) halts the rollback on the first failed compensation; Continue presses on. Precedence for policies is action > group > config. If a compensation itself fails under Stop, a CompensationFailedException surfaces.

Signals

Signals let external code push data or decisions into a running workflow. Inside handle(), awaitSignal() blocks the workflow (by suspending it) until the named signal arrives, then returns its payload:

A timeout turns an unanswered wait into a catchable exception:

A deadline does not enforce itself: the package has no durable timers, so an unanswered wait only times out once the expiration sweep notices — see Expiration & monitoring below. Until then the wait stays open, however long the deadline has been past.

Deliver a signal from anywhere via the handle:

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. A refusal writes nothing.

No $runId? Find the run by workflow and tag, then signal it. Use signalable() (alias active()), not running() — a flow parked on awaitSignal() is Waiting, not Running:

Retry on signal

A step that fails hard takes the whole run with it: the saga rolls back and completed work is undone. retryOnSignal() parks such a step instead — the run waits, nothing rolls back, and when the named signal arrives only that step runs again:

Deliver balance-refilled the way you deliver any other signal and ChargeCard runs again, alone; earlier steps stay completed and un-compensated. The layers stack: Laravel's $tries first, then retryOnSignal(), then continueOnFailure(), then hard failure and compensation. When the budget is spent, the wait times out, or the policy refuses the failure, the step fails exactly as it would have without the policy — same ActionFailedException, same rollback.

A policy worth naming goes in a class instead, and the call site takes the object:

Nothing about the policy is persisted — it is rebuilt by handle() on every replay — and it decides whether to park, not whether to wake: an already-parked step spends its cycle when the signal arrives. The predicate must stay a pure function of its RetryContext; writing to the run it is deciding for raises RetryPolicyReentryException.

A retry consumes no new sequence: the step reuses its own ordinal and action_runs row, so downstream steps land identically whether it retried or not. saga()->step() mirrors the method.

saga-flow:list annotates a parked run with the signal it needs, saga-flow:show gains a Retry column, and two events (ActionAwaitingRetry, ActionRetried) cover the lifecycle.

Side effects

Anything non-deterministic (random values, now(), a UUID, an external read) must be wrapped in sideEffect() so replay reuses the recorded value instead of computing a new one:

The first execution records the value; every later replay of the run returns the same stored value. A factory is not called once the run is rolling back: the seam reads the run's status from the connection that wrote it and ends the pass first, so host code does not run outside a plan that is already being unwound. The same holds for a child workflow started at a new ordinal. See Statuses.

Parallel actions

parallel() runs several actions concurrently (as queued jobs, or inline under runSync) and returns their results as a list:

->waitAllThenFail() lets every step settle before the block fails; failFast() (the config default, ParallelFailurePolicy::FailFast) short-circuits on the first hard failure. Steps in a parallel block can carry their own compensations and optionalAction().

Optional actions

An optional action never fails the flow — its failure is swallowed and a fallback is returned:

You can also mark it declaratively with #[ContinueOnFailure] on the action class.

Child workflows

A workflow can start another workflow and await its result. The child inherits the parent's connection, queue, and tenant context:

Close policies: Abandon (default — leave the child running), Cancel (cancel it), Fail (fail it). A failing child throws ChildWorkflowFailedException (or ChildWorkflowCancelledException) unless you call ->continueParentOnFailure(). The default close policy is configurable (children.default_close_policy) or per class via #[ChildPolicy].

child() is also the only seam that runs another workflow from inside handle(). SagaFlow::create(...) there takes no ordinal, so the run it starts is recognized by nothing, and the next replay starts another one.

Tags & querying

Attach searchable key/value tags at creation, from inside the workflow, or from outside through a FlowHandle:

Tag keys written from outside should not collide with keys the workflow writes in handle(): a workflow $this->tag('x', ...) re-runs on every replay and would overwrite the host value.

Query runs with the fluent, type-safe FlowQuery:

Status shortcuts: running(), waiting(), completed(), failed(), plus active() / signalable() (Pending, Running, or Waiting) for finding a run to deliver a signal to.

Terminals: get(), first(), count(), paginate(), handles(), and builder() (the raw Eloquent builder for ordering/limits).

Expiration & monitoring

Runs, actions, and signal waits can carry deadlines — either explicitly (->expiresAt(...), ->timeoutAfter(...)) or via the configured defaults in monitor.expiration.defaults. Something has to notice an expired deadline; there are two ways to drive that sweep:

Scheduler (recommended). Run the monitor on a schedule:

Queue looping (opt-in). Drive the sweep off the queue worker's idle loop by enabling monitor.queue_looping.enabled (throttled by throttle_seconds). Useful when you have no cron but always-on workers.

If neither is running, no deadline is ever enforced — queue:work alone does not check them. And because the sweep is the only writer of "this deadline passed", deadlines are approximate: a signal delivered after its deadline but before the next sweep is still accepted. Both points are expanded in Expiration & monitoring.

For runs whose progress was lost to a dropped job (rather than a deadline), the doctor can re-dispatch missing actions (repair.redispatch_lost_actions) and re-wake stuck waits (repair.wake_stuck_flows) — enable repair.enabled and either schedule saga-flow:repair or loop it off the worker (repair.queue_looping.enabled), or kick a single run manually with saga-flow:kick {run} / SagaFlow::kick($id). A kick refills the repair budget that repair.max_attempts caps and sends the sequential step the run is parked on its own job back, so the cap holds the automatic pass off rather than ending the run's recovery. Each config key is documented in Expiration & monitoring.

None of these drives a run that is rolling back or finished — a pass begins only for a run that may still start work, so a deadline is enforced once and each compensation on the rollback it planned runs once. A pass already replaying when the rollback commits starts nothing further either: its next step is refused at the claim, and a child workflow or side effect at an ordinal it has not reached before ends the pass instead. The plan that is unwound is drawn once the run is already rolling back, so a step whose owed attempt completed while an earlier plan was being drawn is compensated too. See Statuses.

Queues, locks & idempotency

Every workflow and action runs as a queued job on the configured connection/queue. A run is driven by replaying handle() from the recorded history; each operation is identified by a deterministic (flow_run_id, sequence) pair, so a step that has completed and recorded its result is never repeated — it is reused from history. The WithoutOverlapping locks (locks.*, TTLs and waits in seconds) serialize concurrent drives of the same run so two workers can't advance it at once.

This is not automatic end-to-end idempotency. The reuse guarantee covers recorded steps only — it does not make the work inside an action idempotent. If a job hangs, is retried, or dies after performing its external effect (charging a card, calling an API) but before recording its result, that effect can happen more than once. End-to-end idempotency depends on your action code: use an idempotency key, prefer upserts, or check whether the effect already happened. The (flow_run_id, sequence) pair makes a stable idempotency key to hand downstream. See Queues, locks & idempotency.

Synchronous execution

runSync() drives the whole workflow in-process, using the same single replay loop as the queued path — handy for tests, tinkering, or short workflows:

The queued and synchronous paths are guaranteed to reach the same final database state.

Never call it inside a DB::transaction() of your own. The steps run while your transaction is open, so a rollback afterwards discards every row of the run while the work those rows describe is already done. The same holds for signal(), cancel() and compensate(); only the queued run() is safe. See Queues, locks & idempotency.

Versioning long-running workflows

A workflow may be suspended for days while its code keeps shipping. To change a running workflow's logic without breaking in-flight runs, keep versions in separate classes/directories (App\Workflows\V1\CheckoutWorkflow, App\Workflows\V2\CheckoutWorkflow) and pin a version at creation:

Read the pinned version inside handle() with $this->version(); existing runs keep replaying against the class they were created with.

Octane & multi-tenancy

The engine runs each workflow/action handle() in the tenant the run was created for and reverts afterwards, so nothing leaks between runs on a shared Octane or queue worker.

Each hook is an invokable class name or a [Class::class, 'method'] pair, resolved from the container — write the class with its full namespace, since the config file imports nothing. A hook that cannot be called throws InvalidTenancyHookException; only null turns one off. A closure works too, but php artisan config:cache refuses a config that holds one.

See the multi-tenancy docs for a full stancl/tenancy integration example.

Determinism rules

handle() is replayed from the start on every resume, so it must be deterministic:

Break a rule and the history contract guard raises HistoryContractMismatchException when the replay diverges from the recorded history.

Events

The engine mirrors its flow_events log onto Laravel events you can listen to — e.g. FlowStarted, FlowCompleted, FlowFailed, FlowWaiting, FlowCancelled, ActionCompleted, ActionFailed, CompensationCompleted, ChildWorkflowCompleted, SideEffectRecorded, and more (see src/Events). Register listeners as usual:

FlowCancelled carries an optional ?string $reason, populated when you call $handle->cancel('reason here').

Two events have no flow_events counterpart. A worker that finishes a step whose row has moved on has its outcome refused, and ActionOutcomeRejected / CompensationOutcomeRejected carry what it produced — the value the step returned, in the form the row would have stored, or the throw the engine deliberately does not rethrow. The work is done and nothing local records it, so the payload reaches somewhere you chose rather than nowhere. See Events.

Artisan commands

Command Purpose
saga-flow:list {--status=} {--tag=} {--workflow=} {--limit=50} List runs, newest first, with filters.
saga-flow:show {run} {--compact} Inspect a run: header, actions, signals, compensations, history.
saga-flow:signal {run} {name} {--payload=} Deliver a JSON-payload signal and wake the run.
saga-flow:cancel {run} {--compensate} Cancel a non-terminal run; --compensate rolls back first.
saga-flow:kick {run} Re-drive a stuck run and the step it is parked on.
saga-flow:monitor Expire overdue runs/actions and time out waits.
saga-flow:repair Recover runs whose progress was lost to a dropped job.
saga-flow:prune {--days=} {--before=} {--dry-run} Delete old terminal runs and related rows.
make:workflow {name} Generate a workflow class in App\Workflows.
make:action {name} Generate an action class in App\Actions.

These are CLI only — the package exposes no HTTP routes.

Testing your workflows

Under test, the queued paths must run against a real database queue driven with queue:work --stop-when-empty — the sync driver bypasses the suspend/replay machinery and won't exercise the engine faithfully. runSync() is fine for asserting final state directly:

For queued assertions, set the queue to the database connection, dispatch with ->run(), then drain the queue before asserting. The package's own suite (tests/) is a working reference.

When should I use Durable Workflow instead?

Saga Lara Flow is intentionally a lighter, Laravel-native package. It is focused on queues, Eloquent, an event log, replay, signals, child workflows, and first-class Saga compensations inside a single Laravel application.

If you need a more complete workflow engine — SDK-neutral or polyglot workers, standalone/external workers, Fiber-based execution, strict workflow-definition fingerprinting, worker compatibility fleets, sticky execution, durable timers, schedules, control-plane APIs, rich projections/observability, search attributes, memos, history export/import, replay verification, external payload storage, history budgets, or Temporal/Cadence-style operations — you should evaluate Durable Workflow instead.

Changelog

Please see CHANGELOG for more information on what has changed recently.

Contributing

Please see CONTRIBUTING for details.

Security vulnerabilities

Please review our security policy on how to report security vulnerabilities.

Credits

License

The MIT License (MIT). Please see License File for more information.


All versions of saga-lara-flow with dependencies

PHP Build Version
Package Version
Requires php Version ^8.5
illuminate/contracts Version ^13.0
illuminate/database Version ^13.0
illuminate/queue Version ^13.0
illuminate/support Version ^13.0
laravel/serializable-closure Version ^2.0
spatie/laravel-package-tools Version ^1.16
Composer command for our command line client (download client) This client runs in each environment. You don't need a specific PHP version etc. The first 20 API calls are free. Standard composer command

The package discovery-ukraine/saga-lara-flow contains the following files

Loading the files please wait ...