Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Workflow Engine Core Laravel Package

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.

View on GitHub
Deep Wiki
Context7

Getting Started

Minimal Setup for Laravel Integration

Requirements: PHP 8.3–8.5 (8.5 added in 2.0.0). No ext-curl required unless you use HttpAction.

  1. Install the package (pin the major, since 2.0.0 changed public class names):

    composer require solutionforest/workflow-engine-core:^2.0
    
  2. 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.

  3. 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();
    
  4. 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'])
        );
    });
    
  5. 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]);
    }
    

First Use Case: Order Processing

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).


Implementation Patterns

1. Action Development Pattern

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');
    }
}
  • Use app() / constructor injection rather than new.
  • Return ActionResult::success($data) or ActionResult::failure($message) — do not swallow errors and return success.
  • Log before returning failure; a failed step is retried on resume() in 2.0.0, so keep the action idempotent.

2. Workflow Integration Pattern

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".

3. State Management Pattern

$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>

4. Error Handling Pattern

$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

5. Compound Conditions

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.

6. Testing Pattern

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.

7. Email and Notifications

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

Gotchas and Tips

1. Upgrade Checklist for 2.0.0

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

2. Condition Changes Are Behavioural, Not Just Syntactic

In 1.x, 'a && b' silently returned true. Any existing workflow with compound conditions was taking the first branch unconditionally. After upgrading:

  • Steps that used to run for everyone may now be skipped (or vice versa).
  • Test each compound condition against a known-false dataset and a known-true one.
  • Malformed expressions now throw — expect a wave of StepExecutionExceptions on first deploy if conditions were previously invalid. Dry-run in staging with production-shaped payloads.

3. Missing Keys Now Behave Differently

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.

4. Invalid Definitions Fail at Parse Time

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.

5. Multi-Root Workflows

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.

6. WAITING vs RUNNING

If 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.

7. DelayAction Keys

minutes 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.

8. HttpAction and ext-curl

A 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.

9. Storage Adapter Pitfalls

  • Implement every StorageAdapter method or you get a runtime error mid-workflow.
  • InMemoryStorage is fine for tests and prototypes only — it does not survive the request.
  • Use InMemoryStorage's implementation as the reference for a custom DB adapter; note start() must not clobber an existing instance.

10. State Transition Checks

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.

11. Extension Points

  • Custom actions implementing WorkflowAction (or extending BaseAction) — the intended path for email, HTTP, queue dispatch.
  • Custom 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.

12. Version and CI

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.

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
socialdept/atp-orm
cboxdk/laravel-assistant-client
socialdept/atp-resolver
socialdept/tenant-domains
tautvydasr/blade-class-names
socialdept/color-scales
maherelgamil/controller
coleus/widgets
coleus/table
coleus/users
coleus/music
coleus/health
coleus/calendar
coleus/notes
coleus/support
coleus/settings
coleus/apps
seampact/laravel
emmyconceptng/laravel-ng-postcode
jjsquady/round-robin