Skip to content

Workflow

Directed workflow engine with status-based routing, a typed execution context and a builder API.

Introduction

Multi-step processes in PHP (validating orders, processing payments, sending confirmations) often end up as nested if chains or state-machine frameworks with hundreds of classes. Anyone who needs a simple, directed pipeline where each step determines the next faces too much or too little abstraction.

jardissupport/workflow is a handler graph engine: you define a graph of invokable PHP classes, connect them via status-based transitions, and the engine traverses the graph:

  • Directed graph — every handler returns a status that determines the next handler
  • 7 constantsON_SUCCESS, ON_FAIL, ON_TIMEOUT, ON_SKIP, ON_CANCEL, ON_EVENT, ON_EXIT
  • Typed execution contextWorkflowContext carries every handler invocation as an entry in an ordered execution log; getPrevious() exposes the immediate predecessor's result without the handler needing to know who that was
  • Lossless history — re-invocations of the same handler (retry loops, cross-branch revisits) append a new entry instead of overwriting; getAll(Foo::class) returns every invocation, getLatest(Foo::class) the most recent
  • Fluent builder->node(Handler::class)->onSuccess(Next::class)->onFail(Error::class)
  • DI container-ready — optional factory closure for handler instantiation
  • No framework — handlers are plain PHP invokables with __invoke()

Installation

bash
composer require jardissupport/workflow

GitHub: jardisSupport/workflow

Basic Usage

Defining and Running a Workflow

php
use JardisSupport\Workflow\Workflow;
use JardisSupport\Workflow\Builder\WorkflowBuilder;

$config = (new WorkflowBuilder())
    ->node(ValidateOrderHandler::class)
        ->onSuccess(ChargePaymentHandler::class)
        ->onFail(RejectOrderHandler::class)
    ->node(ChargePaymentHandler::class)
        ->onSuccess(ConfirmOrderHandler::class)
        ->onFail(NotifyFailureHandler::class)
    ->node(ConfirmOrderHandler::class)
    ->node(RejectOrderHandler::class)
    ->node(NotifyFailureHandler::class)
    ->build();

$workflow = new Workflow();
$context  = $workflow($config, $order);

$context->getPrevious();                              // WorkflowResult of the last executed handler
$context->getLatest(ChargePaymentHandler::class);     // most recent invocation of a specific handler
$context->getAll(ChargePaymentHandler::class);        // every invocation in order (e.g. retry attempts)
$context->getChain();                                 // full execution log

Writing Handlers

Every handler is a class with __invoke(). It receives exactly one argument: the WorkflowContextInterface. The return value is a WorkflowResult:

php
use JardisSupport\Contract\Workflow\WorkflowContextInterface;
use JardisSupport\Workflow\WorkflowResult;

final class ValidateOrderHandler
{
    public function __construct(private readonly Order $order) {}

    public function __invoke(WorkflowContextInterface $context): WorkflowResult
    {
        if ($this->order->getTotal() <= 0) {
            return new WorkflowResult(WorkflowResult::ON_FAIL, [
                'error' => 'Invalid order total',
            ]);
        }

        return new WorkflowResult(WorkflowResult::ON_SUCCESS, [
            'validated' => true,
            'tax' => $this->order->getTotal() * 0.19,
        ]);
    }
}

How does the domain object reach the handler? The initial $data argument of $workflow($config, $order) is forwarded exclusively to the handler factory. A typical factory inside a BoundedContext spawns each handler as a BC with $data as the payload: the handler then reads it via $this->payload(). Without a factory, new $class() is used and $data is ignored.

WorkflowResult — Status and Data

php
// Success with data
new WorkflowResult(WorkflowResult::ON_SUCCESS, ['key' => 'value']);

// Business failure
new WorkflowResult(WorkflowResult::ON_FAIL, ['error' => 'Payment declined']);

// Timeout path
new WorkflowResult(WorkflowResult::ON_TIMEOUT, ['after' => '30s']);

// Cancellation
new WorkflowResult(WorkflowResult::ON_CANCEL, ['reason' => 'user cancelled']);

Status and Transitions

All Constants

Every constant is both the status a handler returns and the transition key in the graph:

ConstantValueTypical use
ON_SUCCESS'onSuccess'Successful completion
ON_FAIL'onFail'Business failure (validation, rule violation)
ON_TIMEOUT'onTimeout'Service-side timeout translated to business routing
ON_SKIP'onSkip'Handler not applicable — flow skips to re-convergence point
ON_CANCEL'onCancel'Business cancellation (withdrawal of consent, abort)
ON_EVENT'onEvent'Async hand-off via domain event
ON_EXIT'onExit'Loop/block terminates — continues in surrounding flow

Workflow End

The workflow ends when:

  • No transition is configured for the returned status
  • The transition points to null
  • The last handler has no further transitions

WorkflowContext — typed execution context

WorkflowContext carries the full execution history as a flat, ordered array of WorkflowResult entries (the execution chain). It is created before the first handler runs, passed to every handler as the last argument, and returned to the caller after the workflow terminates.

Reading the predecessor's data

The simplest case: a handler wants the result of the directly preceding step:

php
final class ChargePaymentHandler
{
    public function __construct(private readonly Order $order) {}

    public function __invoke(WorkflowContextInterface $context): WorkflowResult
    {
        // Data of the directly preceding handler
        $previous = $context->getPrevious()?->getData() ?? [];
        $tax = $previous['tax'] ?? 0;

        $paymentId = $this->gateway->charge($this->order->getTotal() + $tax);

        return new WorkflowResult(WorkflowResult::ON_SUCCESS, [
            'paymentId' => $paymentId,
        ]);
    }
}

Targeted lookup of an earlier handler

When a later handler needs a result from much earlier in the chain, it asks for it by FQCN: no payload has to be threaded through every step:

php
$validation = $context->getLatest(ValidateOrderHandler::class)?->getData();
$total = $validation['total'] ?? 0;

Counting retry attempts

In a self-loop every invocation is appended as its own entry: the history is preserved:

php
final class ChargePaymentHandler
{
    public function __construct(private readonly Order $order) {}

    public function __invoke(WorkflowContextInterface $context): WorkflowResult
    {
        $attempt = count($context->getAll(self::class)) + 1;

        if ($attempt > 3) {
            return new WorkflowResult(WorkflowResult::ON_FAIL, [
                'error' => 'Max retries exceeded',
            ]);
        }

        $result = $this->gateway->charge($this->order->getTotal());

        if ($result->isTemporaryFailure()) {
            return new WorkflowResult(WorkflowResult::ON_TIMEOUT, ['attempt' => $attempt]);
        }

        return new WorkflowResult(WorkflowResult::ON_SUCCESS, [
            'paymentId' => $result->getId(),
        ]);
    }
}

Full execution log

getChain() returns the ordered list of all invocations: each entry pairs the handler FQCN with the WorkflowResult it produced. The same handler may appear multiple times.

php
foreach ($context->getChain() as $result) {
    echo "{$result->getHandlerFqcn()}: {$result->getStatus()}\n";
}

Builder API

Fluent Graph Definition

php
$config = (new WorkflowBuilder())
    ->node(StepA::class)
        ->onSuccess(StepB::class)
        ->onFail(StepError::class)
        ->onTimeout(StepTimeout::class)
    ->node(StepB::class)
        ->onSuccess(StepC::class)
    ->node(StepC::class)                // Terminal node (no transitions)
    ->node(StepError::class)
    ->node(StepTimeout::class)
    ->build();

The first node() call defines the entry point of the workflow.

Array API (without builder)

php
use JardisSupport\Workflow\WorkflowConfig;
use JardisSupport\Workflow\WorkflowResult;

$config = new WorkflowConfig();
$config
    ->addNode(StepA::class, [
        WorkflowResult::ON_SUCCESS => StepB::class,
        WorkflowResult::ON_FAIL    => StepError::class,
        WorkflowResult::ON_TIMEOUT => StepA::class,
    ])
    ->addNode(StepB::class)
    ->addNode(StepError::class);

DI Container Integration

php
$workflow = new Workflow(fn(string $class, mixed $data) => $container->make($class, ['data' => $data]));

$context = $workflow($config, $request);

Without a factory, new $class() is used; $data is ignored in that case.

Initial Input

Workflow::__invoke() accepts a single optional $data argument:

php
$context = $workflow($config, $order);

$data is forwarded exclusively to the handler factory, not passed positionally to the handler. The typical pattern inside a BoundedContext: the factory spawns each handler as a BC with $data as the payload; the handler reads it via $this->payload(). Without a factory, new $class() is used and $data is ignored: handlers work exclusively with what is already in the context.

php
// Factory pattern: $data as BC payload
$workflow = new Workflow(
    fn(string $class, mixed $data) => $this->context($class, $data)
);
$context = $workflow($config, $order);
// Handler: public function __invoke(WorkflowContextInterface $context) — reads $order via $this->payload()

Return Value

Workflow::__invoke() returns the WorkflowContextInterface:

php
$context = $workflow($config, $order);

// Result of the last executed handler
$final = $context->getPrevious();
// WorkflowResult(status: 'success', data: ['confirmed' => true, 'transactionId' => 'TX-1'])

// Full execution log — the same handler may appear multiple times
$chain = $context->getChain();
// list<WorkflowResultInterface> — FQCN via $result->getHandlerFqcn(), status via $result->getStatus()
// [
//     WorkflowResult(handler: 'App\\ValidateOrder',    status: 'onSuccess', ...),
//     WorkflowResult(handler: 'App\\ProcessPayment',   status: 'onSuccess', ...),
//     WorkflowResult(handler: 'App\\SendConfirmation', status: 'onSuccess', ...),
// ]

Error Handling

SituationException
Handler class does not existInvalidArgumentException
Handler is not callableInvalidArgumentException
Handler does not return a WorkflowResultInterfaceInvalidArgumentException
Invalid status stringInvalidArgumentException
Builder without nodesInvalidArgumentException

Architecture

Workflow                               ← Orchestrator (engine)
├── WorkflowConfig                     ← Graph definition (nodes + transitions)
├── WorkflowContext                    ← Execution log (append-only chain)
├── WorkflowResult                     ← Handler return value (status + data)
└── Builder/
    ├── WorkflowBuilder                ← Fluent graph definition
    └── WorkflowNodeBuilder            ← Per-node transitions

Directory Structure

src/
├── Workflow.php                    ← Orchestrator
├── WorkflowConfig.php              ← Graph configuration
├── WorkflowContext.php             ← Execution log
├── WorkflowResult.php              ← Value Object (status + data)
└── Builder/
    ├── WorkflowBuilder.php         ← Fluent builder
    └── WorkflowNodeBuilder.php     ← Node configuration

API Reference

Workflow

MethodSignatureDescription
__construct__construct(?Closure $handlerFactory = null)Optional handler factory
__invoke__invoke(WorkflowConfigInterface $config, mixed $data = null): WorkflowContextInterfaceExecute workflow

WorkflowContext

MethodSignatureDescription
appendappend(string $handlerFqcn, WorkflowResultInterface $result): voidAppend a result to the chain (used internally by the engine)
getPreviousgetPrevious(): ?WorkflowResultInterfaceResult of the immediate predecessor
getLatestgetLatest(string $handlerFqcn): ?WorkflowResultInterfaceMost recent result of a specific handler
getAllgetAll(string $handlerFqcn): list<WorkflowResultInterface>All results of a handler in execution order
getChaingetChain(): list<array{handler, result}>Full execution log

WorkflowResult

MethodSignatureDescription
getStatusgetStatus(): stringStatus (one of the ON_* values)
getDatagetData(): mixedData payload
getHandlerFqcngetHandlerFqcn(): ?stringFQCN of the handler (stamped by the engine via withHandler())

WorkflowBuilder

MethodSignatureDescription
nodenode(string $handlerClass): WorkflowNodeBuilderInterfaceDefine node
addTransitionaddTransition(string $name, string $handlerClass): voidAdd a transition to the current node
buildbuild(): WorkflowConfigInterfaceCreate config

WorkflowNodeBuilder

MethodSignatureDescription
onSuccessonSuccess(string $handler): selfSuccess transition
onFailonFail(string $handler): selfFailure transition
onTimeoutonTimeout(string $handler): selfTimeout transition
onSkiponSkip(string $handler): selfSkip transition
onCancelonCancel(string $handler): selfCancel transition
onEventonEvent(string $handler): selfEvent transition
onExitonExit(string $handler): selfExit transition
nodenode(string $handlerClass): WorkflowNodeBuilderInterfaceStart next node (delegates to builder)
buildbuild(): WorkflowConfigInterfaceFinish workflow configuration (delegates to builder)

Complete Example

Order process with validation, payment, confirmation and error handling:

php
use JardisSupport\Contract\Workflow\WorkflowContextInterface;
use JardisSupport\Workflow\Workflow;
use JardisSupport\Workflow\WorkflowResult;
use JardisSupport\Workflow\Builder\WorkflowBuilder;

// Define handlers — every handler receives only WorkflowContextInterface.
// The initial $order input is forwarded to the factory as a BC payload;
// handlers that need it read it via $this->payload().
final class ValidateOrder
{
    public function __construct(private readonly Order $order) {}

    public function __invoke(WorkflowContextInterface $context): WorkflowResult
    {
        if (empty($this->order->getItems())) {
            return new WorkflowResult(WorkflowResult::ON_FAIL, [
                'error' => 'Order contains no items',
            ]);
        }
        return new WorkflowResult(WorkflowResult::ON_SUCCESS, [
            'itemCount' => count($this->order->getItems()),
            'total' => $this->order->getTotal(),
        ]);
    }
}

final class ProcessPayment
{
    public function __construct(private readonly PaymentGateway $gateway) {}

    public function __invoke(WorkflowContextInterface $context): WorkflowResult
    {
        $validation = $context->getLatest(ValidateOrder::class)?->getData() ?? [];
        $total = $validation['total'] ?? 0;

        $result = $this->gateway->charge($total);
        if ($result->isDeclined()) {
            return new WorkflowResult(WorkflowResult::ON_FAIL, [
                'declineReason' => $result->getReason(),
            ]);
        }
        return new WorkflowResult(WorkflowResult::ON_SUCCESS, [
            'transactionId' => $result->getId(),
        ]);
    }
}

final class SendConfirmation
{
    public function __construct(private readonly Mailer $mailer) {}

    public function __invoke(WorkflowContextInterface $context): WorkflowResult
    {
        $validation = $context->getLatest(ValidateOrder::class)?->getData() ?? [];
        $payment    = $context->getLatest(ProcessPayment::class)?->getData() ?? [];
        $this->mailer->sendOrderConfirmation($validation, $payment['transactionId']);

        return new WorkflowResult(WorkflowResult::ON_SUCCESS, [
            'confirmed' => true,
            'transactionId' => $payment['transactionId'],
        ]);
    }
}

final class HandleFailure
{
    public function __construct(private readonly Logger $logger) {}

    public function __invoke(WorkflowContextInterface $context): WorkflowResult
    {
        $previous = $context->getPrevious()?->getData() ?? [];
        $this->logger->error('Order failed', $previous);

        return new WorkflowResult(WorkflowResult::ON_SUCCESS, [
            'notified' => true,
        ]);
    }
}

// Configure graph
$config = (new WorkflowBuilder())
    ->node(ValidateOrder::class)
        ->onSuccess(ProcessPayment::class)
        ->onFail(HandleFailure::class)
    ->node(ProcessPayment::class)
        ->onSuccess(SendConfirmation::class)
        ->onFail(HandleFailure::class)
    ->node(SendConfirmation::class)
    ->node(HandleFailure::class)
    ->build();

// Execute — $order is forwarded to the factory, not to the handler
$workflow = new Workflow(fn(string $class, mixed $data) => $container->make($class, ['order' => $data]));
$context  = $workflow($config, $order);

$final = $context->getPrevious();

if ($final?->getStatus() === WorkflowResult::ON_SUCCESS && ($final->getData()['confirmed'] ?? false)) {
    echo "Order confirmed. Transaction: " . $final->getData()['transactionId'];
} else {
    $data = $final?->getData() ?? [];
    echo "Order failed: " . ($data['error'] ?? $data['declineReason'] ?? 'unknown');
}