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 constants —
ON_SUCCESS,ON_FAIL,ON_TIMEOUT,ON_SKIP,ON_CANCEL,ON_EVENT,ON_EXIT - Typed execution context —
WorkflowContextcarries 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
composer require jardissupport/workflowGitHub: jardisSupport/workflow
Basic Usage
Defining and Running a Workflow
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 logWriting Handlers
Every handler is a class with __invoke(). It receives exactly one argument: the WorkflowContextInterface. The return value is a WorkflowResult:
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
$dataargument of$workflow($config, $order)is forwarded exclusively to the handler factory. A typical factory inside a BoundedContext spawns each handler as a BC with$dataas the payload: the handler then reads it via$this->payload(). Without a factory,new $class()is used and$datais ignored.
WorkflowResult — Status and Data
// 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:
| Constant | Value | Typical 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:
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:
$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:
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.
foreach ($context->getChain() as $result) {
echo "{$result->getHandlerFqcn()}: {$result->getStatus()}\n";
}Builder API
Fluent Graph Definition
$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)
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
$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:
$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.
// 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:
$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
| Situation | Exception |
|---|---|
| Handler class does not exist | InvalidArgumentException |
| Handler is not callable | InvalidArgumentException |
Handler does not return a WorkflowResultInterface | InvalidArgumentException |
| Invalid status string | InvalidArgumentException |
| Builder without nodes | InvalidArgumentException |
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 transitionsDirectory 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 configurationAPI Reference
Workflow
| Method | Signature | Description |
|---|---|---|
__construct | __construct(?Closure $handlerFactory = null) | Optional handler factory |
__invoke | __invoke(WorkflowConfigInterface $config, mixed $data = null): WorkflowContextInterface | Execute workflow |
WorkflowContext
| Method | Signature | Description |
|---|---|---|
append | append(string $handlerFqcn, WorkflowResultInterface $result): void | Append a result to the chain (used internally by the engine) |
getPrevious | getPrevious(): ?WorkflowResultInterface | Result of the immediate predecessor |
getLatest | getLatest(string $handlerFqcn): ?WorkflowResultInterface | Most recent result of a specific handler |
getAll | getAll(string $handlerFqcn): list<WorkflowResultInterface> | All results of a handler in execution order |
getChain | getChain(): list<array{handler, result}> | Full execution log |
WorkflowResult
| Method | Signature | Description |
|---|---|---|
getStatus | getStatus(): string | Status (one of the ON_* values) |
getData | getData(): mixed | Data payload |
getHandlerFqcn | getHandlerFqcn(): ?string | FQCN of the handler (stamped by the engine via withHandler()) |
WorkflowBuilder
| Method | Signature | Description |
|---|---|---|
node | node(string $handlerClass): WorkflowNodeBuilderInterface | Define node |
addTransition | addTransition(string $name, string $handlerClass): void | Add a transition to the current node |
build | build(): WorkflowConfigInterface | Create config |
WorkflowNodeBuilder
| Method | Signature | Description |
|---|---|---|
onSuccess | onSuccess(string $handler): self | Success transition |
onFail | onFail(string $handler): self | Failure transition |
onTimeout | onTimeout(string $handler): self | Timeout transition |
onSkip | onSkip(string $handler): self | Skip transition |
onCancel | onCancel(string $handler): self | Cancel transition |
onEvent | onEvent(string $handler): self | Event transition |
onExit | onExit(string $handler): self | Exit transition |
node | node(string $handlerClass): WorkflowNodeBuilderInterface | Start next node (delegates to builder) |
build | build(): WorkflowConfigInterface | Finish workflow configuration (delegates to builder) |
Complete Example
Order process with validation, payment, confirmation and error handling:
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');
}