solution-forest/workflow-engine-core
A framework-agnostic PHP 8.3+ workflow engine for defining, executing, and tracking workflows with state management, retries, timeouts, and conditions. Extensible and type-safe, with no framework dependencies.
Requirements: PHP 8.3–8.5 (8.5 added in 2.0.0). No ext-curl required unless you use HttpAction.
Install the package (pin the major, since 2.0.0 changed public class names):
composer require solutionforest/workflow-engine-core:^2.0
Run a workflow immediately with the bundled adapter. As of 2.0.0 the package ships Storage\InMemoryStorage, so you no longer need to write a storage adapter just to see the engine work:
use SolutionForest\WorkflowEngine\Core\WorkflowEngine;
use SolutionForest\WorkflowEngine\Storage\InMemoryStorage;
$engine = new WorkflowEngine(new InMemoryStorage(), new NullEventDispatcher());
Confirm the exact namespace (SolutionForest\WorkflowEngine\Storage\InMemoryStorage) with php -r "var_dump(class_exists(...));" — it moved out of tests/ in this release.
Define a simple workflow (app/Workflows/OrderWorkflow.php):
use SolutionForest\WorkflowEngine\Core\WorkflowBuilder;
use App\Actions\ValidateOrderAction;
use App\Actions\ProcessPaymentAction;
return WorkflowBuilder::create('order-processing')
->addStep('validate', ValidateOrderAction::class)
->addStep('process-payment', ProcessPaymentAction::class)
->build();
Bind the engine as a singleton with a real storage adapter and Laravel's event dispatcher:
$this->app->singleton(WorkflowEngine::class, function ($app) {
return new WorkflowEngine(
new DatabaseStorageAdapter(),
new LaravelEventDispatcher($app['events'])
);
});
Trigger it from a controller or job:
public function processOrder(Request $request, WorkflowEngine $engine)
{
$definition = include app_path('Workflows/OrderWorkflow.php');
$instanceId = $engine->start(
'order-processing',
$definition->toArray(),
['order_id' => $request->order_id]
);
return response()->json(['instance_id' => $instanceId]);
}
Order fulfilment (validate → payment → inventory → notify) is still the natural first workflow, and it now benefits from real compound conditions:
$workflow = WorkflowBuilder::create('order-fulfillment')
->addStep('validate-order', ValidateOrderAction::class)
->addStep('process-payment', ProcessPaymentAction::class)
->addStep('run-fraud-check', FraudCheckAction::class)
->addStep('deduct-inventory', DeductInventoryAction::class)
->build();
Branch on conditions attached to transitions, not inside an action (see Gotchas — ConditionAction no longer accepts on_true/on_false).
Laravel actions extend BaseAction and pull dependencies from the container:
use SolutionForest\WorkflowEngine\Actions\BaseAction;
use SolutionForest\WorkflowEngine\Core\WorkflowContext;
use SolutionForest\WorkflowEngine\Core\ActionResult;
use Illuminate\Support\Facades\Log;
class ProcessPaymentAction extends BaseAction
{
public function execute(WorkflowContext $context): ActionResult
{
$paymentGateway = app(PaymentGateway::class);
$result = $paymentGateway->charge(
$context->getData('order_id'),
$context->getData('amount')
);
if ($result->successful()) {
return ActionResult::success(['transaction_id' => $result->id]);
}
Log::error('Payment failed', ['error' => $result->message]);
return ActionResult::failure('Payment processing failed');
}
}
app() / constructor injection rather than new.ActionResult::success($data) or ActionResult::failure($message) — do not swallow errors and return success.resume() in 2.0.0, so keep the action idempotent.Bridge workflow events into Laravel's event/job system in a listener:
public function handle(WorkflowCompletedEvent $event)
{
event(new OrderProcessed($event->workflowId, $event->data));
ProcessOrderConfirmation::dispatch($event->workflowId);
}
Because FAILED is no longer terminal in 2.0.0, also listen for the failure event and decide whether to resume(), escalate to a human, or mark the domain record as needing attention — don't rely on "failed means done".
$instance = $engine->getInstance($request->instance_id);
return response()->json([
'status' => $instance->getState()->value,
'progress' => $instance->getProgress(),
'current_step' => $instance->getCurrentStep()?->id,
]);
New in 2.0.0: workflows blocked on unmet prerequisites report WAITING rather than RUNNING. Treat WAITING as a distinct UI state ("queued / awaiting dependency") and make sure any polling or dashboard code handles a fourth status. WorkflowDefinition::getFirstSteps() is handy when rendering multi-root workflows in the admin UI.
Blade badge:
@php $state = $instance->getState(); @endphp
<div class="status-badge {{ $state->color() }}">
{{ $state->label() }} {{ $state->icon() }}
</div>
$this->renderable(function (StepExecutionException $e, $request) {
return response()->json([
'error' => 'Workflow step failed',
'step' => $e->stepId,
'message' => $e->getMessage(),
'instance_id' => $e->workflowId,
], 500);
});
Retries: the engine does not perform retries or timeouts on step attributes — the 1.x docs claiming otherwise were corrected in 2.0.0. Wrap retryable work in a queued Laravel job with tries/backoff, or re-dispatch the workflow after a resume().
Resuming failures: resume() now works on a FAILED instance and retries the failed step:
$engine->resume($instanceId); // FAILED -> RUNNING is legal in 2.0.0
The condition grammar now supports &&, ||, parentheses and negated groups, and malformed expressions throw instead of collapsing to true:
->addStep('run-fraud-check', FraudCheckAction::class)
->when('order.total > 1000 && (order.vip === false || order.country != "US")', function ($builder) {
$builder->addStep('manual-review', ManualReviewAction::class);
})
Use === / == / != (not = or is). Relational comparisons against a missing key evaluate to false — so guard data shape at the entry point of the workflow if you depend on missing.key < 1000 style checks.
InMemoryStorage is now a first-class dependency, so tests need no custom adapter:
test('order workflow completes successfully', function () {
$engine = new WorkflowEngine(new InMemoryStorage(), new NullEventDispatcher());
$definition = WorkflowBuilder::create('test-order')
->addStep('validate', MockValidationAction::class)
->addStep('process', MockProcessingAction::class)
->build();
$instanceId = $engine->start('test-order', $definition->toArray(), ['order_id' => 123]);
$instance = $engine->getInstance($instanceId);
expect($instance->getState())->toBe(WorkflowState::COMPLETED)
->and($instance->getData('processed'))->toBeTrue();
});
Add a regression test for every multi-branch workflow you own: assert the branch taken, not just COMPLETED. That is exactly the class of bug 2.0.0 fixed.
EmailAction is gone — it never sent mail but reported 'status' => 'sent'. Use fakeEmail() in tests, and implement a real WorkflowAction around Mail::to(...)->send(...) for production:
$builder->fakeEmail('notify', '[email protected]', 'Order Confirmed'); // tests only
| Grep for | Why | Fix |
|---|---|---|
EmailAction |
Renamed to FakeEmailAction; never sent mail |
Implement a real action using Mail |
->email( |
Renamed to fakeEmail() |
Rename the call |
on_true / on_false |
Read but never consumed by ConditionAction |
Branch with a condition on the transition |
= / is in conditions |
Grammar now shared and strict | Use === / == / != |
FAILED immutability assumptions |
FAILED -> RUNNING is now legal |
Re-read state before assuming finality |
resume() on failed |
Now genuinely retries the step | Ensure actions are idempotent |
start() with an existing ID |
No longer overwrites the instance | Generate IDs, or check for an existing instance first |
In 1.x, 'a && b' silently returned true. Any existing workflow with compound conditions was taking the first branch unconditionally. After upgrading:
StepExecutionExceptions on first deploy if conditions were previously invalid. Dry-run in staging with production-shaped payloads.null coerced to 0, so missing.key < 1000 was true in 1.x and is false in 2.0.0. Any workflow that implicitly relied on absent data falling through a numeric comparison will change branch. Add explicit presence checks (Support\Arr::has()) where data shape is not guaranteed.
Workflows with steps reachable only from one another (an orphaned cycle detached from any root) are rejected when the definition is parsed. This surfaces as a builder/parse exception rather than a silent partial run — register a bootstrap test that builds every workflow file in app/Workflows so a bad edit fails CI, not production.
Previously a multi-root definition dropped a branch and still reported completed at 66.67% progress. Fixed, but audit any definitions with more than one getFirstSteps() entry: the new behaviour runs all roots, which may mean duplicated side effects if the second root was previously dead code. Conversely, any workflow that "completed" at a suspiciously round progress value is a hint it was hitting this bug.
WAITING vs RUNNINGIf you poll for RUNNING to decide whether to show a spinner, blocked workflows now park in WAITING and your UI will look idle. Also migrate existing rows: instances sitting in RUNNING from before the upgrade may actually be blocked.
DelayAction Keysminutes and hours are now honoured. Before, delay(hours: 2) paused for ~one second — so any "delayed" reminder or SLA workflow was effectively immediate. Check whether downstream steps (emails, escalations) fired early in production and consider backfilling.
HttpAction and ext-curlA missing ext-curl reports a step failure rather than a fatal error, which means it looks like a business failure rather than a deployment problem. Add ext-curl to your composer.json require block and to your container image; alert on this specific failure message.
StorageAdapter method or you get a runtime error mid-workflow.InMemoryStorage is fine for tests and prototypes only — it does not survive the request.InMemoryStorage's implementation as the reference for a custom DB adapter; note start() must not clobber an existing instance.InvalidWorkflowStateException still fires on illegal transitions. Guard with $instance->getState()->canTransitionTo($target) — but remember FAILED is no longer a dead end, so your guard tables need updating.
WorkflowAction (or extending BaseAction) — the intended path for email, HTTP, queue dispatch.StorageAdapter for persistence; custom event dispatcher to bridge to Laravel's Event facade.WorkflowDefinition::getFirstSteps() and Support\Arr::has() are the supported helpers for root discovery and safe data access.v1.0.0 shipped with these defects inside a stable tag; only 2.0.0 fixes them. Pin ^2.0 and avoid *. The package's own PR CI only started running on pull requests in this release, so review dependabot merges into your own lockfile rather than trusting upstream auto-merge. See SECURITY.md for the disclosure process and scope.
How can I help you explore Laravel packages today?