padosoft/laravel-flow

DX-first workflow / saga / compensation engine for Laravel with native dry-run, reverse-order compensation, business impact tracking, opt-in persistence, and audit events.

Maintainers

Package info

github.com/padosoft/laravel-flow

pkg:composer/padosoft/laravel-flow

Statistics

Installs: 0

Dependents: 1

Suggesters: 0

Stars: 0

Open Issues: 1

v1.0.0 2026-05-05 16:15 UTC

README

Tests Latest Version PHP Version Laravel Version License Total Downloads

Laravel Flow banner

DX-first workflow / saga / compensation engine for Laravel — with native dry-run, configurable compensation strategies, business-impact projection, opt-in persistence, and audit events. Built for Laravel teams that need dry-run, compensation, and persisted run telemetry inside the app they already operate.

laravel-flow is the third deliverable of the Padosoft v4.0 cycle (W5). It is a community Apache-2.0 package, standalone-agnostic (zero references to AskMyDocs / sister packages), and ships with the Padosoft AI vibe-coding pack so you can extend it with Claude Code or GitHub Copilot in minutes — not days.

use Padosoft\LaravelFlow\Facades\Flow;

Flow::define('promotion.create')
    ->withInput(['brand', 'discount_pct', 'starts_at', 'ends_at'])
    ->step('validate', ValidatePromotionInput::class)
    ->step('simulate', SimulatePromotionImpact::class)
        ->withDryRun(true)
    ->step('persist', PersistPromotion::class)
        ->compensateWith(ReversePromotion::class)
    ->register();

$run    = Flow::execute('promotion.create', $input);   // real execution
$dryRun = Flow::dryRun ('promotion.create', $input);   // simulate, no writes

Table of contents

Why this package

Laravel applications routinely need to orchestrate multi-step business workflows that mix:

  • Validations (some safe to skip, some load-bearing).
  • Simulations (project the impact of an operation without writing).
  • Manual sign-off checkpoints (v0.3 approval gates can now pause, resume, or reject persisted runs; CLI approvals and signed webhook outbox delivery are available).
  • Side-effecting writes (DB rows, queue jobs, vendor API calls).
  • Compensation chains (when step N fails, undo step N-1 ... step 1).
  • Audit trails (regulators want to see who did what, when, with which inputs, in which order).

The Laravel ecosystem has plenty of tools for some of these — Bus::chain() for sequence, jobs for async, transaction() for atomicity — but none of them ship with native dry-run, reverse-order saga compensation, and a single fluent surface that a junior dev can read in 30 seconds.

laravel-flow is that surface.

It is deliberately small. v0.1 is in-memory, synchronous, container-resolved. The current v0.2 foundation adds opt-in DB persistence for runs, steps, and audit rows plus queued dispatch, guarded retry metadata, database queue coverage, terminal-run replay, and opt-in parallel compensation for independent compensators. v0.3 has the approval pause primitive, hashed one-time approval-token issuance, persisted Flow::resume() / Flow::reject() APIs, CLI approvals, and signed webhook outbox delivery. The current v1.0 macro ships the package-side dashboard contracts (FlowDashboardReadModel read service, DashboardActionAuthorizer authorization hook with DenyAllAuthorizer default) so a separate companion app (padosoft-laravel-flow-dashboard, see docs/DASHBOARD_APP_SPEC.md) can build the operator UI on a stable headless surface.

Design rationale

Five non-negotiable choices that drove the API:

1. Dry-run is a first-class flag, not a convention

Every handler can declare ->withDryRun(true). When the engine runs in dry mode, it invokes dry-run-aware steps (so they can project impact) and skips the others, returning dry_run_skipped markers. There is no separate "preview" code path to maintain.

2. Compensation walks backwards by default

Saga semantics: when step N fails, the engine walks the previously-completed steps from N-1 back to 1, calling each registered FlowCompensator. There is no "compensate forward" or "best-effort cleanup" mode — predictable rollback every time.

When all compensators for a flow are independent and idempotent, set compensation_strategy=parallel to batch completed compensators through Laravel Concurrency. Reverse order stays the default because many saga rollbacks depend on undoing the newest side effect first. If the engine is constructed with an isolated/non-global container, or the configured Concurrency driver cannot run, parallel strategy falls back to in-process compensation through the injected container.

3. Handlers and compensators are container-resolved classes

step('persist', PersistPromotion::class) not step('persist', fn () => ...). Closures don't survive serialization (queued workers in v0.2), don't get DI, and don't surface in stack traces. Class-based handlers cost one extra file and pay back tenfold in observability.

4. The audit trail is event-driven

When audit_trail_enabled is enabled, normal-case step and compensation transitions dispatch the matching Laravel event, such as FlowStepStarted, FlowStepCompleted, FlowStepFailed, or FlowCompensated. When persistence is enabled, step events are dispatched only after the matching audit append succeeds, and compensation events are skipped if their audit append fails. The host application subscribes once and routes those events to the logger, DB, or metrics backend it already runs. Persisted flow_audit rows are written only for non-dry-run executions when both persistence and audit_trail_enabled are enabled. Dry-runs never write run, step, or audit rows.

5. Standalone-agnostic — zero AskMyDocs symbols

laravel-flow is a community package. It is not coupled to AskMyDocs, the sister patent-box-tracker, or any other Padosoft project. An architecture test enforces this on every CI run by walking src/ with RecursiveDirectoryIterator and asserting forbidden substrings never appear.

Features at a glance

  • Fluent definition builderFlow::define($name)->withInput([...])->step(...)->register().
  • Native dry-runFlow::dryRun($name, $input) simulates without persisting; supporting handlers project impact, others self-skip.
  • Configurable saga compensationcompensateWith(Compensator::class) per step; default reverse-order rollback, plus opt-in parallel batching for independent compensators.
  • Audit events and persisted audit rows — normal-case transitions dispatch matching FlowStep* / FlowCompensated events when audit_trail_enabled=true; persisted flow_audit rows are written only for non-dry-run executions with both persistence.enabled=true and audit_trail_enabled=true, and those rows are append-only during normal runtime but retention-prunable with flow:prune.
  • Business-impact projection — handlers return businessImpact: [...] alongside output, surfaced on every step result.
  • Opt-in persisted executionflow_runs, flow_steps, and flow_audit migrations, Eloquent repositories, immutable run identity updates, correlation/idempotency keys, transaction-scoped step transitions, atomic step upserts, compensate-first runtime-abort recovery, sanitized listener/error storage, clock-aware audit timestamps, redacted JSON payload storage, and retention pruning.
  • Queued dispatch foundationFlow::dispatch($name, $input, $options) validates the flow and queues an after-commit RunFlowJob with a per-dispatch cache lock plus guarded Laravel-native tries/backoff metadata; sync and database queue paths have package coverage.
  • Terminal-run replayphp artisan flow:replay {runId} creates a new persisted run linked to the original via replayed_from_run_id and warns when the current registered definition drifted from stored step metadata.
  • Approval gate pause stateapprovalGate($name) adds a built-in dry-run-aware step that marks the run paused, emits/persists FlowPaused, and, when persistence is enabled, issues a pending approval record.
  • Persisted approval resume/reject APIFlow::resume($token, $payload, $actor) consumes the pending token only while the run is still paused, marks the approval gate succeeded, reconstructs persisted context, and continues downstream steps under a per-run shared cache lock; retries skip downstream steps that already have persisted success rows. Flow::reject($token, $payload, $actor) fails the gate and compensates prior completed steps. Custom persistence backends opt into this API by binding their approval backend to ApprovalRepository, implementing ApprovalDecisionRepository on that approval backend, returning a ConditionalRunRepository from FlowStore::runs(), and applying the package payload redactor consistently for approval decision JSON.
  • Hashed approval-token foundationApprovalTokenManager issues expiring one-time approval records, persists only SHA-256 token hashes, and consumes approve/reject decisions with redacted actor metadata; the plain token is available only from the returned run at issuance time, while event/audit payloads carry non-secret metadata.
  • Signed webhook outbox delivery — lifecycle rows for flow.completed, flow.failed, flow.paused, and flow.resumed are persisted with attempt counts and retry scheduling; php artisan flow:deliver-webhooks signs every payload with an X-Laravel-Flow-Signature header and reschedules transient failures up to a configured retry limit.
  • Headless dashboard contractsFlowDashboardReadModel exposes paginated listRuns, findRun, listApprovals, pendingApprovals, listWebhookOutbox, and aggregated kpis returning immutable DTOs; DashboardActionAuthorizer is the host-app authorization hook with DenyAllAuthorizer registered as the deny-by-default binding (AllowAllAuthorizer ships as an explicit dev opt-in). The companion app spec is at docs/DASHBOARD_APP_SPEC.md.
  • Container-resolved handlers — full DI, type hints, and stack traces.
  • Strict input validationwithInput(['a','b']) throws FlowInputException if a key is missing.
  • Parallel compensation strategycompensation_strategy=parallel batches completed compensators through Laravel Concurrency when compensators are safe to run without reverse-order dependencies.
  • Testbench-friendly — TestCase + stubs ready to copy.
  • 🚀 AI vibe-coding pack included.claude/ directory with skills, rules, agents, commands, and the Padosoft Copilot review loop pre-wired.
  • PHP 8.3 / 8.4 × Laravel 13 matrix on every CI run.

Web admin UI

A full-featured web admin panel for laravel-flow is available at padosoft/laravel-flow-admin.

Laravel Flow admin dashboard

Comparison vs alternatives

Legend: ✅ YES means the capability is first-class in the current product, ⚠️ PARTIAL means it is possible but manual, narrower, or provided through a different model, and ❌ NO means it is not available today.

Feature laravel-flow Durable Workflow (Laravel) Symfony Workflow Temporal AWS Step Functions
Native dry-run with no persistence writes ✅ YES - first-class Flow::dryRun(); no run, step, audit, or compensator writes ❌ NO - not documented as a first-class mode ❌ NO - app must model preview behavior ❌ NO - app must model simulation separately ❌ NO - app must model simulation separately
Reverse-order saga compensation ✅ YES - built-in per-step compensateWith() with default reverse-order rollback and opt-in parallel batching for independent compensators ⚠️ PARTIAL - sagas/error handling are possible, but compensation policy is workflow-defined ⚠️ PARTIAL - manual transition/state design ⚠️ PARTIAL - compensation pattern via workflow code/SDKs ⚠️ PARTIAL - manual cleanup states via Catch
Approval gate as a step type ✅ YES - approvalGate($name) pauses and audits runs, issues hashed expiring token records, reissues a later paused gate token once when an older approved token retries, and per-run shared-lock-guarded Flow::resume() / Flow::reject() continue or compensate persisted runs; duplicate resumes return the current persisted running state instead of re-entering handlers; flow:approve and flow:reject CLI commands cover operator decisions ⚠️ PARTIAL - model manually as a long-running workflow/activity ⚠️ PARTIAL - guards can block transitions but are not resumable approval steps ⚠️ PARTIAL - signals/await patterns, not Laravel step gates ✅ YES - callback/task-token pattern
Signed webhook outbox delivery ✅ YES - flow_webhook_outbox rows for flow.paused/resumed/completed/failed written inside engine transactions; flow:deliver-webhooks signs payloads with HMAC-SHA256 in an X-Laravel-Flow-Signature: t=...,v1=... header, leases pending/stale rows with attempts compare-and-set guard, and reschedules transient failures with exponential backoff up to a configured max ❌ NO - not documented as built-in ❌ NO - not part of the workflow component ⚠️ PARTIAL - signed delivery via custom activities or external integration code, not first-class ⚠️ PARTIAL - integrate with EventBridge/SNS/Lambda; signing is consumer-side
Headless dashboard contracts ✅ YES - package-side FlowDashboardReadModel (paginated listRuns/findRun/listApprovals/listWebhookOutbox/kpis returning immutable DTOs) plus DashboardActionAuthorizer interface bound to DenyAllAuthorizer by default; companion app brief at docs/DASHBOARD_APP_SPEC.md keeps the package itself headless ❌ NO - no read-model contract; consume Eloquent records directly ❌ NO - app-defined, no read-model contract ✅ YES - first-class Temporal Web UI plus gRPC/REST APIs (managed dashboard, not a Composer-path-repo headless contract) ✅ YES - first-class AWS Console execution graph + history (managed dashboard, not a Composer-path-repo headless contract)
Web admin UI ✅ YES - companion admin panel available at padosoft/laravel-flow-admin for runs, approvals, failures, outbox, and operational KPIs ⚠️ PARTIAL - optional Waterline UI, tied to that package's durable workflow model ❌ NO - no bundled admin panel ✅ YES - Temporal Web UI for workflow executions and history ✅ YES - AWS Console for state-machine executions and history
@api/@internal source marking + contract-test pinning ✅ YES - every public class is annotated @api (SemVer-covered) or @internal (implementation detail); tests/Contract/PublicApiContractTest pins class names, public methods, and constants so a follow-up patch cannot silently rename or remove the v1.0 surface ❌ NO - no in-source visibility tags or contract-test pinning documented ⚠️ PARTIAL - Symfony framework uses @internal annotations selectively; no companion contract-test pinning ⚠️ PARTIAL - SDKs document stability but no Laravel-package-level contract test surface ⚠️ PARTIAL - service-level API stability via AWS SLAs, not in-source per-class markers
Migration guides from competitors ✅ YES - docs/MIGRATION_DURABLE.md (Temporal-style mapping with trade-off table), docs/MIGRATION_SYMFONY.md (state-machine to linear-flow mapping with event listener table), docs/UPGRADE.md (v0.1→v1.0 + SemVer policy) ❌ NO - not shipped ❌ NO - not shipped ⚠️ PARTIAL - upgrade docs between Temporal versions exist; no migration-from-competitors docs ❌ NO - AWS migration tooling targets services, not other workflow engines
Container-resolved PHP handlers ✅ YES - handlers and compensators resolve through Laravel's container ✅ YES - PHP workflow/activity classes ✅ YES - Symfony services/listeners ❌ NO - worker model is outside Laravel's container ❌ NO - Lambda/service fanout
Audit trail and event hooks ✅ YES - FlowStep* / FlowCompensated events plus optional flow_audit rows ⚠️ PARTIAL - status tracking and Laravel event integration ✅ YES - workflow events and optional audit trail ✅ YES - managed workflow event history ✅ YES - execution history plus CloudWatch/CloudTrail integrations
In-memory default with opt-in app DB persistence ✅ YES - memory by default; DB runs/steps/audit only when enabled ❌ NO - durable persistence is central to the engine ⚠️ PARTIAL - marking store is app-defined ❌ NO - dedicated Temporal service/cluster ❌ NO - managed AWS service
Redacted JSON persistence ✅ YES - configurable key redaction before run/step/audit payload storage ❌ NO - not documented as built-in ❌ NO - app-defined storage concern ⚠️ PARTIAL - custom payload codecs/converters, not Laravel key config ⚠️ PARTIAL - service-level data handling, not Laravel key config
Correlation and idempotency keys ✅ YES - first-class FlowExecutionOptions with length validation and persisted reuse ❌ NO - not documented as first-class execution metadata ❌ NO - app-defined ⚠️ PARTIAL - workflow/activity IDs and idempotency patterns ⚠️ PARTIAL - execution names/tokens, service-specific semantics
Successful-step output aggregation ✅ YES - persisted successful outputs rehydrate idempotent run reuse ⚠️ PARTIAL - workflow/activity outputs exist, but this package contract is not documented ❌ NO - app-defined ✅ YES - workflow history/result model ⚠️ PARTIAL - state input/output paths, not Laravel step result objects
Transaction-scoped transition writes ⚠️ PARTIAL - step transitions use repository transactions and atomic upserts; compensation audit/finalization can be separate writes ⚠️ PARTIAL - package/app persistence model ⚠️ PARTIAL - marking store/app transaction concern ✅ YES - managed event-history durability ✅ YES - managed execution-history durability
Runtime-abort recovery before surfacing infrastructure failures ✅ YES - best-effort failure state plus compensation before rethrow ⚠️ PARTIAL - retries/error handling, recovery policy is workflow-defined ⚠️ PARTIAL - app-defined ✅ YES - durable execution/retry recovery ⚠️ PARTIAL - Retry, Catch, and redrive behavior
Retention pruning for persisted telemetry ✅ YES - flow:prune keeps pending/running rows intact ❌ NO - not documented as built-in ❌ NO - app-defined ⚠️ PARTIAL - service retention configuration, not package command ⚠️ PARTIAL - managed history/log retention, not app command
Business-impact projection on every result ✅ YES - businessImpact is part of every FlowStepResult ❌ NO - not documented ❌ NO - not a workflow component concern ❌ NO - app-defined ❌ NO - app-defined
Queue-backed workers today ⚠️ PARTIAL - Flow::dispatch() queues an after-commit RunFlowJob with per-dispatch locking, configurable lock-held release delay, completed-duplicate no-op handling, guarded Laravel-native tries/backoff metadata, and sync/database queue coverage ✅ YES - Laravel queue/worker support ❌ NO - not native ✅ YES - worker-based execution ✅ YES - managed orchestration
Replay/redrive of failed executions today ⚠️ PARTIAL - flow:replay {runId} creates a new linked run from persisted terminal input and warns on definition drift; deterministic event-history replay is not shipped ⚠️ PARTIAL - durable long-running workflow model; exact replay semantics differ ❌ NO - not native ✅ YES - deterministic replay/event history ✅ YES - Standard Workflow redrive
Low setup friction ✅ YES - composer require plus optional config/migration publish ⚠️ PARTIAL - Laravel queues/workers and optional Waterline UI ✅ YES - Composer package and framework config ❌ NO - service/cluster plus workers ❌ NO - AWS account, IAM, state-machine definitions
Self-hosted with no external workflow service ✅ YES - runs inside the Laravel app; DB optional ✅ YES - Laravel app/queue infrastructure ✅ YES - application component ❌ NO - requires Temporal service/cluster ❌ NO - AWS-managed service
Open-source package/license ✅ YES - Apache-2.0 ✅ YES - MIT ✅ YES - MIT ✅ YES - MIT core/server and SDKs ❌ NO - proprietary managed service

Competitor snapshot checked against Durable Workflow, Symfony Workflow, Temporal, and AWS Step Functions documentation on 2026-05-05. laravel-flow is deliberately positioned as the lightest Laravel-native dependency in the table. If you already run Temporal or AWS Step Functions and need their queue/replay/redrive guarantees today, use them. If you want saga semantics, dry-run, business-impact projection, opt-in persistence, signed webhook delivery, headless dashboard contracts, and a companion web admin UI inside an existing Laravel app, this is the package.

Installation

composer require padosoft/laravel-flow

Publish the config (optional — the engine works with defaults):

php artisan vendor:publish --tag=laravel-flow-config

Publish the v0.2 persistence migrations only when you are opting into DB-backed storage:

php artisan vendor:publish --tag=laravel-flow-migrations
php artisan migrate

The in-memory engine path still works without migrations. To persist runtime runs, enable LARAVEL_FLOW_PERSISTENCE_ENABLED=true; dry-runs remain simulation-only and do not write to the database.

Prune old terminal persistence records with the built-in retention command:

php artisan flow:prune --days=90 --dry-run
php artisan flow:prune --days=90

flow:prune deletes only terminal runs (succeeded, failed, compensated, aborted) with finished_at older than the cutoff. Matching flow_steps and flow_audit rows are deleted in the same batch transaction; running and pending rows are left untouched. Use LARAVEL_FLOW_RETENTION_DAYS=90 to make --days optional, and --force for non-interactive production runs.

Replay a terminal persisted run as a new linked run:

php artisan flow:replay 00000000-0000-4000-8000-000000000001

flow:replay reads the original persisted input and current registered definition, then creates a fresh persisted run with replayed_from_run_id pointing at the original. It does not mutate the original run and refuses to replay pending/running rows. If the stored step names/handlers differ from the current definition, the command warns and still uses the current definition. Redacted persisted input stays redacted during replay.

Requirements

  • PHP 8.3+
  • Laravel 13.x

Quick start

use Padosoft\LaravelFlow\Facades\Flow;
use Padosoft\LaravelFlow\FlowContext;
use Padosoft\LaravelFlow\FlowStepHandler;
use Padosoft\LaravelFlow\FlowStepResult;

// 1. Define a handler.
class ValidatePromotionInput implements FlowStepHandler
{
    public function execute(FlowContext $context): FlowStepResult
    {
        if ($context->input['discount_pct'] > 90) {
            return FlowStepResult::failed(new \DomainException('Discount > 90%'));
        }

        return FlowStepResult::success(['validated_at' => now()->toIso8601String()]);
    }
}

// 2. Register a flow.
Flow::define('promotion.create')
    ->withInput(['brand', 'discount_pct'])
    ->step('validate', ValidatePromotionInput::class)
    ->register();

// 3. Execute.
$run = Flow::execute('promotion.create', ['brand' => 'acme', 'discount_pct' => 25]);

if ($run->status === \Padosoft\LaravelFlow\FlowRun::STATUS_SUCCEEDED) {
    // Done.
} else {
    // $run->failedStep tells you which step blew up.
    // $run->compensated tells you whether rollback ran.
}

Usage examples

Correlation and idempotency

use Padosoft\LaravelFlow\FlowExecutionOptions;

$run = Flow::execute(
    'promotion.create',
    $input,
    FlowExecutionOptions::make(
        correlationId: 'checkout-2026-0001',
        idempotencyKey: 'tenant-42:promotion-abc',
    ),
);

When persistence is enabled, correlationId and idempotencyKey are stored on flow_runs. Both values are trimmed, empty strings become null, and non-empty values are limited to 255 characters to match the published migrations. A later persisted execution with the same idempotency key returns the existing run state without executing handlers again. Dry-runs still avoid persistence writes.

Queue dispatch foundation

use Padosoft\LaravelFlow\FlowExecutionOptions;

Flow::dispatch(
    'promotion.create',
    $input,
    FlowExecutionOptions::make(
        correlationId: 'checkout-2026-0001',
        idempotencyKey: 'tenant-42:promotion-abc',
    ),
);

Flow::dispatch() validates the registered definition and required input before queuing RunFlowJob. The job dispatches after the current database transaction commits and takes a per-dispatch cache lock before execution; duplicate deliveries that find the lock held are released after the smaller of queue.lock_retry_seconds and queue.lock_seconds, while duplicates that arrive after the dispatch completed are acknowledged as no-ops. Configure queue.tries and queue.backoff_seconds to stamp Laravel-native retry metadata into the queued job payload for workers and Horizon. Because async Laravel workers retry the whole RunFlowJob from the beginning, policies that can re-run a flow are rejected until step-level retry semantics are available. The sync queue driver ignores worker retry metadata and remains allowed for local/test dispatches. The worker resolves the current FlowEngine and executes the same definition with the serialized input and execution options. Compensation still defaults to reverse-order rollback; set compensation_strategy=parallel only for flows whose compensators are independent and idempotent.

Approval gates

Flow::define('promotion.publish')
    ->step('prepare', PreparePromotion::class)
        ->compensateWith(UndoPreparedPromotion::class)
    ->approvalGate('manager')
    ->step('publish', PublishPromotion::class)
    ->register();

// Enable laravel-flow.persistence.enabled before executing so a persisted approval token is issued.
$pausedRun = Flow::execute('promotion.publish', $input);
$token = $pausedRun->approvalTokens['manager']->plainTextToken;

$resumedRun = Flow::resume($token, ['decision' => 'approved'], ['user_id' => 123]);
// To reject instead of approving that pending token:
// $rejectedRun = Flow::reject($token, ['reason' => 'duplicate'], ['user_id' => 123]);

Approval resume/reject requires persistence and a shared cache store that supports Laravel atomic locks. Custom persistence backends must bind their approval backend to ApprovalRepository, implement ApprovalDecisionRepository on that approval backend for decided-token lookup, run-status-conditional consumes, and one-time pending-token reissue for downstream approval gates, return a ConditionalRunRepository from FlowStore::runs() for paused-run claims, keep the approval backend and FlowStore::runs() on the same durable storage boundary so conditional consumes and paused-run claims observe the same run state, and apply the package payload redactor consistently to approval decision JSON. Backends that delegate approval JSON redaction to laravel-flow can implement RedactorAwareApprovalRepository so Flow::resume() / Flow::reject() inject the same execution-frozen redactor used for step/run writes. The process-local array cache store is rejected for approval decisions because concurrent HTTP/API requests would not share the lock. The decision lock is keyed by run, so retries of older gate tokens serialize with a later gate resume on the same run. The plain token is returned only on the immediate paused run, while flow_approvals stores the current token hash, at most one previous hash for a reissued downstream-gate token, expiry, redacted decision payload, and redacted actor metadata. Custom approval repositories that support downstream token reissue must resolve both current and previous hashes in lookup, consume, and expiry paths so both handed-out tokens remain valid until expiry or decision. If a retry of an older approved token finds the run paused at a later approval gate, Laravel Flow can rotate that later gate's pending token hash once while it is still unexpired, keep the previous hash valid, refresh the paused-step expiry metadata, and return a fresh plain token on the current run. If the per-run lock is already held, old-token retries ask the caller to retry instead of returning a tokenless paused run or reissuing without owning the lock. Flow::resume() marks the approval gate succeeded and continues from the next unfinished step without rerunning prior or already-persisted downstream successes. If a duplicate resume sees that the run is already running, it returns the current persisted state instead of re-entering downstream handlers while another process may still own the side effect. Later steps receive the stored approval payload and stored approval actor metadata, so configured secret keys are redacted before handler context is reconstructed. Flow::reject() records the gate as failed and runs compensators for prior completed steps. Resume reconstructs context from persisted input and step outputs, so values redacted before storage remain redacted after resume.

Signed webhook outbox delivery

php artisan flow:deliver-webhooks

// Tune throughput and pacing for transient errors.
php artisan flow:deliver-webhooks --batch=10 --sleep-ms=250

flow:deliver-webhooks processes pending rows from flow_webhook_outbox and sends signed HTTP POST payloads to laravel-flow.webhook.url. Each call can be retried with exponential backoff and a configurable attempt cap. Rows are marked delivered, pending (with a future available_at), or failed.

The command requires laravel-flow.webhook.enabled=true and a non-empty valid laravel-flow.webhook.url.

Compensation chain (saga rollback)

class PersistPromotion implements FlowStepHandler { /* writes a DB row */ }
class ReversePromotion implements FlowCompensator  { /* deletes the row */ }

Flow::define('promotion.create')
    ->withInput(['brand', 'discount_pct'])
    ->step('validate', ValidatePromotionInput::class)
    ->step('persist', PersistPromotion::class)
        ->compensateWith(ReversePromotion::class)
    ->step('publish', PublishPromotionToCDN::class)  // imagine this fails
    ->register();

$run = Flow::execute('promotion.create', $input);

// If 'publish' fails:
//   $run->status      === FlowRun::STATUS_FAILED
//   $run->failedStep  === 'publish'
//   $run->compensated === true   // ReversePromotion ran

Dry-run / impact projection

class SimulatePromotionImpact implements FlowStepHandler
{
    public function execute(FlowContext $context): FlowStepResult
    {
        $impact = [
            'expected_users_reached' => 12_400,
            'projected_revenue_eur'  => 18_900.00,
        ];

        return FlowStepResult::success(output: [], businessImpact: $impact);
    }
}

Flow::define('promotion.create')
    ->step('simulate', SimulatePromotionImpact::class)
        ->withDryRun(true)
    ->step('persist', PersistPromotion::class)        // skipped in dry mode
    ->register();

$dryRun = Flow::dryRun('promotion.create', $input);

$businessImpact = $dryRun->stepResults['simulate']->businessImpact;
// ['expected_users_reached' => 12400, 'projected_revenue_eur' => 18900.00]

Subscribing to the audit trail

use Illuminate\Support\Facades\Event;
use Padosoft\LaravelFlow\Events\FlowStepStarted;
use Padosoft\LaravelFlow\Events\FlowStepCompleted;
use Padosoft\LaravelFlow\Events\FlowStepFailed;
use Padosoft\LaravelFlow\Events\FlowCompensated;

Event::listen(function (FlowStepStarted $e) {
    logger()->info('flow.step.started', [
        'flow_run_id'     => $e->flowRunId,
        'definition_name' => $e->definitionName,
        'step_name'       => $e->stepName,
        'dry_run'         => $e->dryRun,
    ]);
});

Event::listen(function (FlowStepFailed $e) {
    // Wire to Sentry / Datadog / your audit table.
});

Configuration reference

// config/laravel-flow.php
$queueLockSeconds = env('LARAVEL_FLOW_QUEUE_LOCK_SECONDS', 3600);
$queueLockRetrySeconds = env('LARAVEL_FLOW_QUEUE_LOCK_RETRY_SECONDS', 30);
$queueTries = env('LARAVEL_FLOW_QUEUE_TRIES', null);
$queueBackoffSeconds = env('LARAVEL_FLOW_QUEUE_BACKOFF_SECONDS', null);
$approvalTokenTtlMinutes = env('LARAVEL_FLOW_APPROVAL_TOKEN_TTL_MINUTES', 1440);
$webhookTimeoutSeconds = env('LARAVEL_FLOW_WEBHOOK_TIMEOUT_SECONDS', 5);
$webhookRetryBaseDelaySeconds = env('LARAVEL_FLOW_WEBHOOK_RETRY_BASE_DELAY_SECONDS', 30);
$webhookMaxAttempts = env('LARAVEL_FLOW_WEBHOOK_MAX_ATTEMPTS', 3);

return [
    'default_storage'        => env('LARAVEL_FLOW_STORAGE', null),
    'persistence'            => [
        'enabled'   => env('LARAVEL_FLOW_PERSISTENCE_ENABLED', false),
        'redaction' => [
            'enabled'     => env('LARAVEL_FLOW_REDACTION_ENABLED', true),
            'replacement' => env('LARAVEL_FLOW_REDACTION_REPLACEMENT', '[redacted]'),
            'keys'        => ['api_key', 'authorization', 'password', 'secret', 'token'],
        ],
        'retention' => [
            'days' => env('LARAVEL_FLOW_RETENTION_DAYS', null),
        ],
    ],
    'queue'                  => [
        'lock_store'   => env('LARAVEL_FLOW_QUEUE_LOCK_STORE', null),
        'lock_seconds' => is_numeric($queueLockSeconds) && (int) $queueLockSeconds >= 1
            ? (int) $queueLockSeconds
            : 3600,
        'lock_retry_seconds' => is_numeric($queueLockRetrySeconds) && (int) $queueLockRetrySeconds >= 1
            ? (int) $queueLockRetrySeconds
            : 30,
        'tries' => $queueTries,
        'backoff_seconds' => $queueBackoffSeconds, // set LARAVEL_FLOW_QUEUE_BACKOFF_SECONDS=5,30,120 for a retry schedule
    ],
    'approval'               => [
        'token_ttl_minutes' => is_numeric($approvalTokenTtlMinutes) && (int) $approvalTokenTtlMinutes >= 1
            ? (int) $approvalTokenTtlMinutes
            : 1440,
    ],
    'webhook'               => [
        'enabled' => env('LARAVEL_FLOW_WEBHOOK_ENABLED', false),
        'url' => env('LARAVEL_FLOW_WEBHOOK_URL', ''),
        'secret' => env('LARAVEL_FLOW_WEBHOOK_SECRET', null),
        'retry_base_delay_seconds' => is_numeric($webhookRetryBaseDelaySeconds) && (int) $webhookRetryBaseDelaySeconds > 0
            ? (int) $webhookRetryBaseDelaySeconds
            : 30,
        'max_attempts' => is_numeric($webhookMaxAttempts) && (int) $webhookMaxAttempts > 0
            ? (int) $webhookMaxAttempts
            : 3,
        'timeout_seconds' => is_numeric($webhookTimeoutSeconds) && (int) $webhookTimeoutSeconds >= 1
            ? (int) $webhookTimeoutSeconds
            : 5,
    ],
    'audit_trail_enabled'    => env('LARAVEL_FLOW_AUDIT_ENABLED', true), // events; DB audit rows require this=true, persistence.enabled=true, and a non-dry-run execution
    'dry_run_default'        => env('LARAVEL_FLOW_DRY_RUN_DEFAULT', false),
    'step_timeout_seconds'   => (int) env('LARAVEL_FLOW_STEP_TIMEOUT', 300), // v0.2
    'compensation_strategy'  => env('LARAVEL_FLOW_COMPENSATION', 'reverse-order'), // reverse-order|parallel
    'compensation_parallel_driver' => env('LARAVEL_FLOW_COMPENSATION_PARALLEL_DRIVER', 'process'),
];
Key Default Effect
default_storage null DB connection used by persistence repositories. Inherits app default when null.
persistence.enabled false Enables synchronous engine writes to flow_runs and flow_steps; flow_audit writes also require audit_trail_enabled=true and a non-dry-run execution. Dry-runs do not write.
persistence.redaction common secrets Redacts configured JSON payload keys before run, step, and audit payloads are stored.
persistence.retention.days null Default retention window for php artisan flow:prune; pass --days to override per run.
queue.lock_store null Cache store used for queued run locks and approval resume/reject locks. When null, Flow::dispatch() captures the current cache.default store into the job; queued workers require a shared atomic lock store, while process-local array is accepted only with the sync queue driver. Approval resume/reject always rejects array.
queue.lock_seconds 3600 TTL for the per-dispatch queue lock used by RunFlowJob and the per-run approval decision lock used by Flow::resume() / Flow::reject(); set it longer than the expected maximum queued run or approval resume/reject runtime because Laravel's portable lock contract cannot renew it.
queue.lock_retry_seconds 30 Delay before a duplicate delivery retries when the per-dispatch lock is still held; the job caps it at queue.lock_seconds.
queue.tries null Optional Laravel job attempts value stamped onto RunFlowJob; null leaves the worker/connection default in control, while 0 preserves Laravel's unlimited-retry semantics. Async values that can retry the whole run are rejected until step-level retry or replay semantics are available.
queue.backoff_seconds null Optional Laravel job backoff value stamped onto RunFlowJob; use a single integer or comma-separated/list values such as 5,30,120. Async backoff schedules that can retry the whole run are rejected until step-level retry or replay semantics are available.
approval.token_ttl_minutes 1440 Expiry window for ApprovalTokenManager one-time tokens consumed by Flow::resume() / Flow::reject(). Only token hashes are stored in flow_approvals; the plain token is returned once from issuance.
webhook.enabled false Enables signed lifecycle delivery. Set true only when your app can reach the webhook URL from the command schedule or worker.
webhook.url '' URL endpoint receiving lifecycle event JSON from flow:deliver-webhooks; the command rejects empty or syntactically invalid URL values (HTTPS strongly recommended for production).
webhook.secret null Shared HMAC secret for signing X-Laravel-Flow-Signature: t=...,v1=... headers. Leave empty to disable signing.
webhook.retry_base_delay_seconds 30 Base delay (seconds) for exponential backoff between outbox retries.
webhook.max_attempts 3 Maximum delivery attempts before flow_webhook_outbox.status becomes failed.
webhook.timeout_seconds 5 Per-request delivery timeout in seconds (must be >=1).
audit_trail_enabled true When false, suppresses every FlowStep* / FlowCompensated event and persisted audit row; persisted audit rows also require persistence and a non-dry-run execution.
dry_run_default false When true, Flow::execute() behaves like dryRun() — guard rail for staging environments.
step_timeout_seconds 300 Reserved for follow-up queued step execution; the current RunFlowJob dispatch slice does not enforce per-step timeouts.
compensation_strategy reverse-order Supported values are reverse-order and parallel; use parallel only when completed compensators are independent, idempotent, and safe without reverse-order dependencies.
compensation_parallel_driver process Laravel Concurrency driver used only for compensation_strategy=parallel and only when the engine uses the global Laravel container; set sync for deterministic local/tests or process/fork for actual parallelism when your app supports it. If the driver cannot be resolved or run, the engine falls back to injected-container in-process compensation.

When persistence is enabled, synchronous FlowStep* listener or persistence failures are rethrown after the engine records best-effort recovery state and compensates completed steps. FlowCompensated listener failures are swallowed after the compensation audit row is durable so rollback is not interrupted. Wrap Flow::execute() in application-level exception handling anywhere infrastructure outages must be surfaced separately from business step failures.

Custom FlowStore implementations that need the same per-execution PayloadRedactor used by engine error-text sanitization should implement Padosoft\LaravelFlow\Contracts\RedactorAwareFlowStore. The engine calls withPayloadRedactor() once per persisted execution before writing run, step, and audit telemetry; implementations that keep transaction state on the store should return the same instance or a state-sharing decorator. PayloadRedactor decorators that wrap the package execution-scoped redactor should implement Padosoft\LaravelFlow\Contracts\CurrentPayloadRedactorProvider so multi-field repository writes can reuse one stable inner redactor without changing each JSON payload shape.

Architecture

┌────────────────────┐     define / register      ┌─────────────────────┐
│ FlowDefinitionBuilder ──────────────────────────► FlowDefinition       │
└──────────┬─────────┘                             │  - name             │
           │                                       │  - requiredInputs   │
           │                                       │  - list<FlowStep>   │
           │                                       └──────────┬──────────┘
           │                                                  │
           │  Flow::execute / Flow::dryRun                    ▼
           ▼                                       ┌─────────────────────┐
   ┌──────────────────┐  iterate steps             │ FlowEngine          │
   │   FlowEngine     │ ───────────────────────────►   - definitions[]   │
   │  ::execute()     │                            │   - container DI    │
   └──────┬───────────┘                            │   - event dispatch  │
          │                                        └─────────┬───────────┘
          │ container->make(handlerFqcn)                     │
          ▼                                                  │
   ┌──────────────────┐                                      │
   │ FlowStepHandler  │ ──► FlowStepResult ─────────────────►│
   └──────────────────┘                                      │
          │ failure                                          │
          ▼                                                  │
   ┌──────────────────┐                                      │
   │ Compensation     │ walks backwards                      │
   │ ::reverse-order  │ ──► FlowCompensator::compensate()    │
   └──────────────────┘                                      │
                                                             ▼
                                                   ┌─────────────────────┐
                                                   │ FlowRun             │
                                                   │  - id (uuid v4)     │
                                                   │  - status           │
                                                   │  - failedStep       │
                                                   │  - compensated      │
                                                   │  - stepResults{}    │
                                                   │  - startedAt/finishedAt
                                                   └─────────────────────┘

Every box is one PHP class under src/. The engine path is still synchronous and in-memory by default; when persistence is enabled, runtime runs and steps are written to flow_runs and flow_steps for non-dry-run executions. Audit transitions are written to flow_audit only for non-dry-run executions while persistence and audit_trail_enabled are both enabled. Dry-runs never write audit rows. Flow::dispatch() queues an after-commit RunFlowJob with per-dispatch locking, database queue coverage, and guarded Laravel retry/backoff metadata. flow:replay creates new linked runs from terminal persisted input, and compensation_strategy=parallel batches independent compensators through Laravel Concurrency.

Public API surface (v1.0)

The package marks every class with @api (stable, SemVer-covered) or @internal (implementation detail, not covered by SemVer). Public surface:

  • Facade: Padosoft\LaravelFlow\Facades\Flow.
  • Engine + DTOs: FlowEngine, FlowDefinitionBuilder, FlowDefinition, FlowStep, FlowExecutionOptions, FlowRun, FlowStepResult, FlowContext, IssuedApprovalToken, ApprovalGate, ApprovalTokenManager, WebhookDeliveryClient, WebhookDeliveryResult.
  • Hooks: FlowStepHandler, FlowCompensator interfaces.
  • Events: every class under Padosoft\LaravelFlow\Events\*.
  • Exceptions: every class under Padosoft\LaravelFlow\Exceptions\*.
  • Extension contracts: every interface under Padosoft\LaravelFlow\Contracts\* (custom FlowStore, RunRepository, StepRunRepository, AuditRepository, ApprovalRepository, ApprovalDecisionRepository, ConditionalRunRepository, PayloadRedactor, CurrentPayloadRedactorProvider, RedactorAwareFlowStore, RedactorAwareApprovalRepository implementations).
  • Dashboard contracts: FlowDashboardReadModel, the read DTOs in Padosoft\LaravelFlow\Dashboard\*, and DashboardActionAuthorizer + the DenyAllAuthorizer / AllowAllAuthorizer bindings.

The tests/Contract/PublicApiContractTest testsuite pins the v1.0 surface so a follow-up patch cannot silently rename or remove an @api class, method, or constant. Internal namespaces (Persistence, Models, Queue, Jobs, Console) may change in any minor release; route consumers through the public contracts instead. See docs/UPGRADE.md for the full SemVer policy and upgrade chain.

Companion dashboard

The package itself is headless — there is no embedded UI. The companion app padosoft/padosoft-laravel-flow-dashboard (separate repo) consumes the dashboard contracts via Composer path repository during development and from Packagist in production. The complete brief for an AI agent or human team building that app is at docs/DASHBOARD_APP_SPEC.md.

AI vibe-coding pack

🚀 Every Padosoft package ships with the same vibe-coding pack — drop the .claude/ directory into Claude Code or GitHub Copilot and you get:

  • Skills under .claude/skills/ — reviewer-validated playbooks for copilot-pr-review-loop, pre-push-self-review, test-count-readme-sync, and more.
  • Rules under .claude/rules/ — coding standards (type hints, early return, no debug in commits, code structure, naming conventions, PR workflow). For laravel-flow, repo-local rules define the Laravel 13 package baseline and PR workflow.
  • Agents under .claude/agents/ — pre-wired sub-agent definitions (admin-interface-architect, playwright-enterprise-tester).
  • Commands under .claude/commands/ — slash-command templates (/create-job, /domain-scaffold, /playwright-tester, /pagespeed-review).
  • Instructions under .claude/instructions/ — runtime safety guardrails (testing-safety.md).

The pack is the same baseline used across all padosoft/* repos. It is opt-in: delete .claude/ if you don't use Claude Code or Copilot — nothing else depends on it.

Testing — Default + Live

The default phpunit invocation runs only the offline Unit + Architecture testsuites and never makes a network call:

composer install
composer validate --strict --no-check-publish
composer format:test
composer analyse
composer test

The Live testsuite is opt-in and reserved for v0.2+ scenarios that need a real external dependency (queue worker, webhook receiver). Every Live test self-skips unless LARAVEL_FLOW_LIVE=1 is set:

LARAVEL_FLOW_LIVE=1 vendor/bin/phpunit --testsuite Live

CI runs Pint (style), PHPStan (level 6), and the Unit + Architecture suites through Composer scripts on the PHP 8.3 / 8.4 × Laravel 13 matrix for pushes to main and PRs targeting main or task/**. PHP 8.5 is intentionally not a hard gate until Laravel/Testbench dependency support is reliable enough for this package.

Roadmap

Version Scope Target
v0.1 In-memory engine, fluent builder, dry-run, reverse-order compensation, four audit event classes, business-impact field on results, Facade. Architecture test enforces standalone-agnostic. code complete
v0.2 Persistence core: flow_runs / flow_steps / flow_audit tables, synchronous engine writes, redacted payload storage, correlation/idempotency keys, terminal-run retention pruning, queued dispatch foundation with per-dispatch locking, database queue coverage, guarded Laravel-native retry/backoff metadata, flow:replay for terminal persisted runs, and opt-in parallel compensation for independent compensators. Q3 2026
v0.3 Approval-gate primitive, hashed one-time approval-token issuance, persisted API flow resume/reject controls, CLI approval commands, and signed webhook outbox delivery now landed (flow:approve, flow:reject, flow:deliver-webhooks included). Q4 2026
v1.0 Package-side dashboard contracts (FlowDashboardReadModel, DashboardActionAuthorizer, DenyAllAuthorizer default), stable API marking, semver guarantee, migration helpers from Durable Workflow / Symfony Workflow, and companion app spec at docs/DASHBOARD_APP_SPEC.md. 2027

Contributing

See CONTRIBUTING.md. Community PRs target main; enterprise roadmap work uses the macro/subtask PR loop documented in AGENTS.md.

Security

Report vulnerabilities privately to lorenzo.padovani@padosoft.com per SECURITY.md. Operational guarantees the package enforces:

  • Approval tokens are SHA-256 hashed at rest. The plain token is returned only on the immediate FlowRun at issuance time and is never recoverable from flow_approvals. The dashboard contract takes a token hash, not the plain value, so authorization can run without the secret.
  • JSON payload redaction. flow_runs.input/output/business_impact, flow_steps.input/output/business_impact, flow_audit.payload, and flow_approvals.payload/actor pass through the configured PayloadRedactor before storage. Default keys cover common secret-looking fields; the policy is configurable through laravel-flow.persistence.redaction. The dashboard read DTOs (RunDetail, ApprovalSummary) return whatever is stored — host apps that disable persistence.redaction.enabled MUST add their own redaction layer before rendering.
  • Webhook payload signing. When webhook.secret is set, every outbox delivery carries X-Laravel-Flow-Signature: t=<unix>,v1=<hmac-sha256> so receivers can verify authenticity. Empty secret disables signing rather than emitting a bogus header.
  • Dashboard authorization is deny-by-default. DashboardActionAuthorizer is bound to DenyAllAuthorizer. Production deployments MUST replace the binding with their own RBAC implementation. AllowAllAuthorizer is shipped explicitly for development and never registered automatically.
  • Append-only audit at runtime. flow_audit rows cannot be updated or deleted via Eloquent save()/delete(). Bulk update() / delete() / forceDelete() are blocked by a custom Eloquent builder. The flow:prune retention command is the only supported deletion path and is documented as such.
  • Run-level locking. Flow::resume() and Flow::reject() claim a per-run shared cache lock to serialize approval decisions. The process-local array cache store is rejected because it cannot synchronize across HTTP / queue workers.
  • No secrets in logs or error messages. Engine text-redaction normalises common secret keys across snake_case / kebab-case / camelCase before persisting exception messages.

Enterprise notes

The package is designed to live inside a Laravel application's process and database, not in a separate workflow service. That keeps the operational footprint small (no dedicated workflow cluster) at the cost of cross-language workflows and managed multi-region failover. If you need either, evaluate a managed durable-workflow runtime instead — see docs/MIGRATION_DURABLE.md for the trade-off table.

For teams adopting laravel-flow:

  • Use macro branches and subtask PRs documented in AGENTS.md. Every PR requires GitHub Copilot Code Review plus the local Composer-script gates (composer validate --strict --no-check-publish, composer format:test, composer analyse, composer test).
  • Bind a custom DashboardActionAuthorizer before exposing the dashboard read model to operators. The default DenyAllAuthorizer is intentional.
  • Schedule flow:prune to bound flow_audit and flow_webhook_outbox growth. Retention is the only supported audit-deletion path.
  • Schedule flow:deliver-webhooks (e.g. once a minute) when webhook delivery is enabled. The command is idempotent and uses lease-based row claims with a compare-and-set guard, so multiple concurrent workers are safe.
  • Configure queue.lock_store to a shared atomic cache (redis, memcached, database, dynamodb) before enabling queued dispatch with multiple workers. The array store is accepted only on the sync queue driver and is rejected for approval decisions.
  • Read docs/UPGRADE.md before bumping major versions; v1.0 marked the public-vs-internal contract and SemVer policy.

License

Apache-2.0.

Built by Padosoft — part of the v4.0 ecosystem.