Looking to hire Laravel developers? Try LaraJobs

laravel-workflow-engine maintained by ssulima

Description
Database-configurable workflow engine for Laravel — steps with dependencies, manual approval gates, retries with full attempt history, and support for many parallel runs.
Last update
2026/07/26 20:19 (dev-master)
License
Downloads
2

Comments
comments powered by Disqus

Laravel Workflow Engine

Tests Latest Version License

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 stable step_{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