ssulima / laravel-workflow-engine
Database-configurable workflow engine for Laravel — steps with dependencies, manual approval gates, retries with full attempt history, and support for many parallel runs.
Package info
github.com/ssulima/laravel-workflow-engine
pkg:composer/ssulima/laravel-workflow-engine
Requires
- php: ^8.3
- illuminate/cache: ^12.0|^13.0
- illuminate/database: ^12.0|^13.0
- illuminate/queue: ^12.0|^13.0
- illuminate/support: ^12.0|^13.0
Requires (Dev)
- orchestra/testbench: ^9.0|^10.0
- pestphp/pest: ^3.0
- pestphp/pest-plugin-laravel: ^3.0
- phpunit/phpunit: ^11.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-08-26 18:32:42 UTC
README
Database-configurable workflow engine for Laravel 12/13.
- Workflow definitions and their steps live in the database — steps reference handler classes in your codebase by string, so no redeploy is needed to reorder or reconfigure a workflow.
- Each run carries its own isolated
context, built from arbitrary input parameters plus every step's result under a stablestep_{order}key, so many runs of the same workflow can execute in parallel without interfering with each other. - Steps can require manual approval — the run stops and waits until a human approves or rejects, then resumes exactly where it left off.
- Failed steps can be retried individually. Every attempt is kept in a full history table; only the latest attempt's result feeds into the run's context and downstream steps — similar to retrying a single job in a GitLab CI pipeline.
- Full logging at every state transition (step started, passed, failed, awaiting approval, approved, rejected, retried, run completed).
- Safe under concurrency: per-run cache locks and database-backed step state prevent duplicate execution of a single run's steps, while different runs are never blocked by each other.
Approval and retry routes use the api and auth middleware by default.
Add application-specific authorization middleware or policies as well; the
package cannot infer which users may approve a particular workflow.
Installation
composer require ssulima/laravel-workflow-engine php artisan vendor:publish --tag=workflow-engine-migrations php artisan vendor:publish --tag=workflow-engine-config php artisan migrate
Running the queue worker
Every step is dispatched as a queued job on a dedicated queue (workflows
by default, configurable via WORKFLOW_ENGINE_QUEUE / config('workflow-engine.queue')).
Nothing in a workflow run will actually execute until a worker is listening
on that queue:
php artisan queue:work --queue=workflows
To run every workflow job in a fresh subprocess, use the package command:
php artisan workflow:listen
This is a convenience wrapper around Laravel's queue:listen. The listener
starts queue:work --once for each job, so application code and configuration
are loaded again before the next workflow step. The queue can still be
overridden with WORKFLOW_ENGINE_QUEUE or --queue=....
Note on --tries: RunWorkflowStepJob hardcodes public int $tries = 1,
which overrides any --tries=N you pass on the command line. This is
intentional — automatic queue-level retries would race with the package's
own attempt tracking (WorkflowStepAttempt) and could create duplicate
attempts for a single failure. All retries are meant to go through
WorkflowRetryService::retry() / retryFrom() instead (see below), which is
what keeps the full, ordered attempt history intact. So:
# fine — --tries is simply ignored for this job, no need to pass it php artisan queue:work --queue=workflows # also fine, just redundant php artisan queue:work --queue=workflows --tries=3
In production, run multiple worker processes (or a Horizon supervisor with
maxProcesses > 1) if you want several workflow runs to progress in
parallel. Different runs never block each other; the WithoutOverlapping
middleware on RunWorkflowStepJob only prevents two attempts of the same
run from executing at once.
Defining a workflow
use Ssulima\WorkflowEngine\Models\WorkflowDefinition; $definition = WorkflowDefinition::create([ 'key' => 'order-processing', 'name' => 'Order processing', ]); $definition->allSteps()->createMany([ [ 'order' => 1, 'job_class' => \App\Workflows\ValidateOrderStep::class, 'step_config' => ['min_total' => 1], ], [ 'order' => 2, 'job_class' => \App\Workflows\ChargePaymentStep::class, 'step_config' => ['gateway' => 'stripe'], 'requires_approval' => true, ], [ 'order' => 3, 'job_class' => \App\Workflows\SendConfirmationStep::class, ], ]);
Implementing a step
namespace App\Workflows; use Ssulima\WorkflowEngine\Contracts\WorkflowStepHandler; use Ssulima\WorkflowEngine\Models\WorkflowRun; class ChargePaymentStep implements WorkflowStepHandler { public function handle(WorkflowRun $run, array $config): array { // $run->context has the initial input and previous results under // keys such as $run->context['step_1'] and $run->context['step_2'] // $config is this step's step_config from the database return ['payment_charged' => true]; } }
Starting a run
use Ssulima\WorkflowEngine\Services\WorkflowRunner; $run = app(WorkflowRunner::class)->start( workflowKey: 'order-processing', input: ['order_id' => $order->id], correlationId: "order-{$order->id}", );
Fetching a run or step (e.g. to approve or retry it)
approve(), reject(), retry(), and retryFrom() all take a WorkflowStep
model instance — you first need to look one up. A few common cases:
From an HTTP request (e.g. a "Retry" button in your own UI). Since the
built-in controllers use route-model binding, you generally don't need to
fetch anything manually — Laravel resolves WorkflowStep $step from the
{step} route parameter for you:
Route::post('/my-custom-retry/{step}', function (WorkflowStep $step) { app(WorkflowRetryService::class)->retry($step, userId: auth()->id()); });
By your own business identifier, via the run's correlation_id (set
when you called start()):
use Ssulima\WorkflowEngine\Models\WorkflowRun; $run = WorkflowRun::where('correlation_id', "order-{$order->id}")->firstOrFail(); // grab a specific step by its position in the workflow... $step = $run->steps()->where('order', 2)->firstOrFail(); // ...or the one currently blocking the run, regardless of position $step = $run->steps()->where('status', 'awaiting_approval')->first(); $step ??= $run->steps()->where('status', 'failed')->first();
Everything currently waiting on a human, e.g. to build an approvals inbox/dashboard:
use Ssulima\WorkflowEngine\Models\WorkflowStep; $pendingApprovals = WorkflowStep::where('status', 'awaiting_approval') ->with(['workflowRun.definition', 'workflowDefinitionStep']) ->orderBy('created_at') ->get(); $failedSteps = WorkflowStep::where('status', 'failed') ->with(['workflowRun.definition', 'workflowDefinitionStep']) ->orderBy('updated_at', 'desc') ->get();
All steps and their full attempt history for a given run (e.g. a timeline view similar to a CI pipeline's job list):
$run = WorkflowRun::with(['steps.attempts', 'steps.workflowDefinitionStep']) ->where('correlation_id', "order-{$order->id}") ->firstOrFail(); foreach ($run->steps as $step) { // $step->status — status of the latest attempt // $step->attempt_count — how many times this step has been attempted // $step->attempts — every attempt, oldest first // $step->latestAttempt — the attempt whose result is in // $run->context['step_'.$step->order] }
Once you have the WorkflowStep instance, pass it straight into the
approval/retry services shown below.
Approving / rejecting a gated step
use Ssulima\WorkflowEngine\Services\WorkflowApprovalService; app(WorkflowApprovalService::class)->approve($step, userId: auth()->id()); app(WorkflowApprovalService::class)->reject($step, 'reason', userId: auth()->id());
Retrying a failed step
use Ssulima\WorkflowEngine\Services\WorkflowRetryService; app(WorkflowRetryService::class)->retry($step, userId: auth()->id()); app(WorkflowRetryService::class)->retryFrom($step, userId: auth()->id());
retry() and retryFrom() can repeat any step. retry() reruns one step;
retryFrom() also resets all later steps and rebuilds the context from the
input and successful preceding steps. When a step is repeated, its result
replaces the previous value under that step's step_{order} key.
Configuration
See config/workflow-engine.php after publishing — queue name, table names,
the user model used for approvals, and lock TTLs are all configurable.
The initial workflow job is dispatched after the run transaction commits. Use a cache driver with atomic locks (Redis, database, or another supported driver) in production. The lock TTL should be longer than the maximum expected step execution time; the database-backed step state check also prevents a second job from executing an already-running step if the lock expires.
Testing
composer test
To generate a line, method, and class coverage report, enable the Xdebug coverage mode for the command:
XDEBUG_MODE=coverage composer test:coverage
The test suite uses Orchestra Testbench with an in-memory SQLite database, the synchronous queue, and the array cache. The synchronous queue is intentional: it verifies the complete workflow transition immediately, including approval and retry flows. For a coverage report, Xdebug must be installed and its coverage mode enabled; otherwise PHPUnit can still run the tests but cannot calculate coverage.
License
MIT