laravel-workflow-engine maintained by ssulima
Laravel Workflow Engine
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