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.
Requires
- php: ^8.3
- illuminate/cache: ^13.0
- illuminate/console: ^13.0
- illuminate/contracts: ^13.0
- illuminate/database: ^13.0
- illuminate/queue: ^13.0
- illuminate/support: ^13.0
Requires (Dev)
- laravel/pint: ^1.18
- orchestra/testbench: ^11.0
- phpstan/phpstan: ^2.0
- phpunit/phpunit: ^11.5|^12.5
Suggests
- padosoft/laravel-patent-box-tracker: Tracks R&D activity (Italian Patent Box) across repositories that depend on laravel-flow — useful when flow definitions are part of a tax-deductible R&D project.
- dev-main
- v1.0.0
- v0.1.0
- dev-imgbot
- dev-task/release-docs-v1
- dev-task/release-readme-changelog-foldback
- dev-task/v10-stable-api-migrations
- dev-task/v10-api-marking-and-contracts
- dev-task/dashboard-contracts
- dev-task/dashboard-readmodel-and-actions
- dev-task/v03-approval-webhooks
- dev-task/v03-webhook-delivery
- dev-task/v03-webhook-outbox
- dev-task/v02-persistence
This package is auto-updated.
Last update: 2026-05-06 21:34:43 UTC
README
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
- Design rationale
- Features at a glance
- Web admin UI
- Comparison vs alternatives
- Installation
- Quick start
- Usage examples
- Configuration reference
- Architecture
- AI vibe-coding pack
- Testing — Default + Live
- Roadmap
- Contributing
- Security
- License
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 builder —
Flow::define($name)->withInput([...])->step(...)->register(). - Native dry-run —
Flow::dryRun($name, $input)simulates without persisting; supporting handlers project impact, others self-skip. - Configurable saga compensation —
compensateWith(Compensator::class)per step; default reverse-order rollback, plus opt-inparallelbatching for independent compensators. - Audit events and persisted audit rows — normal-case transitions dispatch matching
FlowStep*/FlowCompensatedevents whenaudit_trail_enabled=true; persistedflow_auditrows are written only for non-dry-run executions with bothpersistence.enabled=trueandaudit_trail_enabled=true, and those rows are append-only during normal runtime but retention-prunable withflow:prune. - Business-impact projection — handlers return
businessImpact: [...]alongside output, surfaced on every step result. - Opt-in persisted execution —
flow_runs,flow_steps, andflow_auditmigrations, 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 foundation —
Flow::dispatch($name, $input, $options)validates the flow and queues an after-commitRunFlowJobwith a per-dispatch cache lock plus guarded Laravel-native tries/backoff metadata; sync and database queue paths have package coverage. - Terminal-run replay —
php artisan flow:replay {runId}creates a new persisted run linked to the original viareplayed_from_run_idand warns when the current registered definition drifted from stored step metadata. - Approval gate pause state —
approvalGate($name)adds a built-in dry-run-aware step that marks the runpaused, emits/persistsFlowPaused, and, when persistence is enabled, issues a pending approval record. - Persisted approval resume/reject API —
Flow::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 toApprovalRepository, implementingApprovalDecisionRepositoryon that approval backend, returning aConditionalRunRepositoryfromFlowStore::runs(), and applying the package payload redactor consistently for approval decision JSON. - Hashed approval-token foundation —
ApprovalTokenManagerissues 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, andflow.resumedare persisted with attempt counts and retry scheduling;php artisan flow:deliver-webhookssigns every payload with anX-Laravel-Flow-Signatureheader and reschedules transient failures up to a configured retry limit. - Headless dashboard contracts —
FlowDashboardReadModelexposes paginatedlistRuns,findRun,listApprovals,pendingApprovals,listWebhookOutbox, and aggregatedkpisreturning immutable DTOs;DashboardActionAuthorizeris the host-app authorization hook withDenyAllAuthorizerregistered as the deny-by-default binding (AllowAllAuthorizerships as an explicit dev opt-in). The companion app spec is atdocs/DASHBOARD_APP_SPEC.md. - Container-resolved handlers — full DI, type hints, and stack traces.
- Strict input validation —
withInput(['a','b'])throwsFlowInputExceptionif a key is missing. - Parallel compensation strategy —
compensation_strategy=parallelbatches 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.
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,FlowCompensatorinterfaces. - Events: every class under
Padosoft\LaravelFlow\Events\*. - Exceptions: every class under
Padosoft\LaravelFlow\Exceptions\*. - Extension contracts: every interface under
Padosoft\LaravelFlow\Contracts\*(customFlowStore,RunRepository,StepRunRepository,AuditRepository,ApprovalRepository,ApprovalDecisionRepository,ConditionalRunRepository,PayloadRedactor,CurrentPayloadRedactorProvider,RedactorAwareFlowStore,RedactorAwareApprovalRepositoryimplementations). - Dashboard contracts:
FlowDashboardReadModel, the read DTOs inPadosoft\LaravelFlow\Dashboard\*, andDashboardActionAuthorizer+ theDenyAllAuthorizer/AllowAllAuthorizerbindings.
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 forcopilot-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). Forlaravel-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
FlowRunat issuance time and is never recoverable fromflow_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, andflow_approvals.payload/actorpass through the configuredPayloadRedactorbefore storage. Default keys cover common secret-looking fields; the policy is configurable throughlaravel-flow.persistence.redaction. The dashboard read DTOs (RunDetail,ApprovalSummary) return whatever is stored — host apps that disablepersistence.redaction.enabledMUST add their own redaction layer before rendering. - Webhook payload signing. When
webhook.secretis set, every outbox delivery carriesX-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.
DashboardActionAuthorizeris bound toDenyAllAuthorizer. Production deployments MUST replace the binding with their own RBAC implementation.AllowAllAuthorizeris shipped explicitly for development and never registered automatically. - Append-only audit at runtime.
flow_auditrows cannot be updated or deleted via Eloquentsave()/delete(). Bulkupdate()/delete()/forceDelete()are blocked by a custom Eloquent builder. Theflow:pruneretention command is the only supported deletion path and is documented as such. - Run-level locking.
Flow::resume()andFlow::reject()claim a per-run shared cache lock to serialize approval decisions. The process-localarraycache 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
DashboardActionAuthorizerbefore exposing the dashboard read model to operators. The defaultDenyAllAuthorizeris intentional. - Schedule
flow:pruneto boundflow_auditandflow_webhook_outboxgrowth. 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_storeto a shared atomic cache (redis,memcached,database,dynamodb) before enabling queued dispatch with multiple workers. Thearraystore is accepted only on thesyncqueue driver and is rejected for approval decisions. - Read
docs/UPGRADE.mdbefore bumping major versions; v1.0 marked the public-vs-internal contract and SemVer policy.
License
Built by Padosoft — part of the v4.0 ecosystem.

