jardissupport / workflow
Process engine for request-response use cases: declarative node graphs, typed results, routing, sub-processes
Requires
- php: >=8.3
- jardissupport/contracts: ^2.15
Requires (Dev)
- jardissupport/scheduling: ^1.2
- jardissupport/validation: ^1.7.1
- phpstan/phpstan: 2.1.56
- phpunit/phpunit: 10.5.63
- squizlabs/php_codesniffer: 3.13.6
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-10-23 01:24:10 UTC
README
Part of Jardis — the Domain-Driven Design platform for PHP. You model your domain; Jardis generates the production-ready hexagonal code (DTOs, Command/Query handlers, repositories, persistence). This package is part of the open-source foundation that generated code runs on.
Process engine for one request-response use case in PHP. A process is a declared graph of nodes; the runner walks the graph, collects the events and answers with one typed ProcessOutcome — status, response object, errors, events. The engine is the graph and nothing around it: input validation, transaction, failure forms and logging belong to the door in front of the runner (see jardissupport/response).
Features
- One run, one call —
ProcessRunner::run()takes input and answers with aProcessOutcome; a node that rejects ends the run with a 4xx, a node that throws is thrown on unchanged - Declarative graph —
ProcessDefinitiondescribes nodes, routes (onTrue/onFalse/onSkip/onTimeout/onCancel/onExit), terminal ends, loop limit and per-node declarations; frozen bybuild() - Three node kinds — Action (your code), Call (a command, query or sub-process), Rule (a business check); all share one contract:
callable(ProcessContext): NodeResult - Reject instead of route —
NodeResult::reject()ends the run at once with a 4xx; it is not a route - Two answer forms —
Answer::LastNode(default: the data of the last step) orAnswer::Merged(the data of all steps united), declared per process - Typed answers — every node can declare a response class
- Events at the result — events travel with the
NodeResult; the caller publishes them after the commit - Immutable chain —
ProcessContextexposes input and the results of the nodes run so far - Strict routing — a route without a declared key raises
UnroutedStatusException; an explicitnulltarget is a declared end - No door inside — no validation, no transaction, no logging, no failure forms: what a node throws leaves the runner unchanged
Installation
composer require jardissupport/workflow
Requires PHP 8.3+ and jardissupport/contracts ^2.12 (ResponseStatus, DomainResponseInterface). Nothing else.
Concepts
| Concept | Meaning |
|---|---|
| Process | One run in one call. Declared by a ProcessDefinition, run by a ProcessRunner |
| Node | A step of the graph. Action: your code. Call: a command, query, sub-process or cross-context call. Rule: a business check |
| Route | Route::OnTrue / OnFalse (yes/no), OnSkip, OnTimeout, OnCancel, OnExit. Routes carry no data |
| Reject | NodeResult::reject(4xx, ...). Not a route: the run ends and the outcome carries the status, the message key, the context and the errors of the reject |
| End | Declared with terminal() (a route mapped to null) |
| Answer | The form of the response of a successful run, declared with answer(Answer): the last step or all steps united |
| Door | Everything in front of and around the engine: input check, transaction, failure forms, logging. Not part of this package |
Quick Start
All examples below run top to bottom as one script; classes are shown without a namespace.
use JardisSupport\Contract\Kernel\ResponseStatus; use JardisSupport\Workflow\Data\Answer; use JardisSupport\Workflow\Data\NodeResult; use JardisSupport\Workflow\Data\ProcessContext; use JardisSupport\Workflow\Data\ProcessDefinition; use JardisSupport\Workflow\Data\Route; use JardisSupport\Workflow\Data\RuleMode; use JardisSupport\Workflow\ProcessRunner; // Input and event of the process final class PlaceOrder { public function __construct(public ?string $customer, public int $qty) { } } final class OrderPlaced { public function __construct(public string $orderNo) { } }
Nodes
A node is any callable(ProcessContext): NodeResult. The runner asks the node factory for it when the node is due, by the name the definition declares (in generated code a class name). The factory is \Closure(class-string): object - it only constructs; the package checks that the object is callable and raises a ConstructionException naming its class when it is not.
// Rule node: a check that rejects with 422 or lets the run go on final class CheckCredit { public function __invoke(ProcessContext $context): NodeResult { return $context->input()->qty > 100 ? NodeResult::reject(ResponseStatus::RuleViolation, 'order.creditExceeded', ['rule' => 'CreditLimit']) : NodeResult::route(Route::OnTrue); // no data: this node adds nothing to the answer } } // The typed context: generated processes extend ProcessContext with typed getters final class CheckoutContext extends ProcessContext { public function reservedSku(): ?string { $data = $this->latest(ReserveStock::class)?->data(); return is_array($data) ? $data['sku'] : null; } } // Action node: your code, here one write into a store final class ReserveStock { public function __construct(private ArrayObject $store) { } public function __invoke(ProcessContext $context): NodeResult { $qty = $context->input()->qty; $this->store->append(['sku' => 'A-1', 'qty' => $qty]); return NodeResult::ok(['sku' => 'A-1', 'qty' => $qty]); } } // The last node: answers and attaches an event final class ConfirmOrder { public function __invoke(CheckoutContext $context): NodeResult { $orderNo = 'O-' . $context->reservedSku(); return NodeResult::ok(['orderNo' => $orderNo, 'status' => 'confirmed']) ->withEvents(new OrderPlaced($orderNo)); } }
Definition
$definition = ProcessDefinition::for(PlaceOrder::class, 'PlaceOrder') ->context(CheckoutContext::class) ->answer(Answer::Merged) ->node(CheckCredit::class)->onTrue(ReserveStock::class) ->rejects(ResponseStatus::RuleViolation, 'order.creditExceeded') ->node(ReserveStock::class)->onTrue(ConfirmOrder::class) ->node(ConfirmOrder::class)->terminal() ->build();
The first node() is the entry node. onTrue(...) and friends, terminal(), rejects(), input() and response() apply to the node declared last. After build() every mutator throws a ConstructionException.
Run
$store = new ArrayObject(); $runner = new ProcessRunner(); $nodeFactory = static fn(string $class): object => match ($class) { CheckCredit::class => new CheckCredit(), ReserveStock::class => new ReserveStock($store), ConfirmOrder::class => new ConfirmOrder(), }; $outcome = $runner->run($definition, new PlaceOrder('ACME', 2), $nodeFactory); $outcome->status(); // ResponseStatus::Success (200) $outcome->response(); // {sku: 'A-1', qty: 2, orderNo: 'O-A-1', status: 'confirmed'} - all steps united (Answer::Merged) $outcome->events(); // [OrderPlaced {orderNo: 'O-A-1'}] $rejected = $runner->run($definition, new PlaceOrder('ACME', 500), $nodeFactory); $rejected->status(); // ResponseStatus::RuleViolation (422) $rejected->messageKey(); // 'order.creditExceeded' $rejected->context(); // ['rule' => 'CreditLimit'] $rejected->response(); // empty object, the failure form is the door's business
Definition API
ProcessDefinition (src/Data/ProcessDefinition.php), all methods fluent unless noted:
| Method | Effect |
|---|---|
ProcessDefinition::for(string $inputClass, string $name) |
Start a definition (static); empty arguments throw ConstructionException |
context(string $contextClass) |
Context class of the run, must extend ProcessContext (default: ProcessContext) |
answer(Answer $answer) |
Form of the response of a successful run: Answer::LastNode (default) or Answer::Merged |
loopLimit(int $loopLimit) |
Maximum node steps of one run (default ProcessDefinition::DEFAULT_LOOP_LIMIT = 100, minimum 1) |
ruleMode(string $node, RuleMode $mode) |
RuleMode::Guard or RuleMode::Decision for a rule node |
node(string $node) |
Declare a node and make it the current one |
onTrue / onFalse / onSkip / onTimeout / onCancel / onExit (?string $target) |
Route of the current node; null declares a terminal end |
terminal(Route ...$routes) |
Terminal end for the given routes (none given: onTrue) |
rejects(ResponseStatus $status, string $messageKey) |
Declare a 4xx the current node may reject with and the message key it answers for that status (non-4xx throws) |
input(string $property) |
Name the process input property that feeds the current node; its 400 rejects answer field paths below it |
response(string $responseClass, string ...$fields) |
Typed answer of the current node, see below |
answerResponse(string $responseClass, string ...$fields) |
Typed answer of the whole process, only with Answer::Merged, see "Answer of a run" |
build() |
Validate the graph (unknown targets, unknown rule-mode node) and freeze |
Declarations for the door, which reads them and acts on them - the engine itself ignores them: transactional(bool) (default on), validate(string $validatorClass), requires(string ...$requires).
Getters: getInputClass(), getName(), getContextClass(), getAnswer(), isTransactional(), getValidatorClass(), getRequires(), getLoopLimit(), getRuleMode($node), getRejects($node), getInput($node), getResponse($node), getResponseClass($node), getAnswerResponseClass(), getAnswerResponse(), isBuilt().
response(class, fields...): the data array of the node is turned into an object of class through its constructor, with named arguments. The fields name the subset of constructor parameters taken from the data (none named: all). A required parameter without a value, or an unknown class, throws a ConstructionException. Only an array result of a routed node is converted; a node without a declaration keeps its data.
Routing is strict. A node that returns a route the definition has no key for throws UnroutedStatusException. terminal() and onX(null) declare the end on purpose.
Runner
new ProcessRunner() // no constructor arguments, no state between runs
run(
ProcessDefinition $definition,
object $input,
\Closure $nodeFactory, // \Closure(class-string): object - builds the node; callable-ness is checked by the package
int $depth = 0 // nesting depth: 0 at the outermost level, else the depth of the calling run plus one
): ProcessOutcome
The node factory is called each time a node is due, also on every loop pass. Around each node: GuardLoop, CollectEvents, HydrateResponse, ApplyRuleMode. The runner does not check the input, open a transaction or log: the engine answers a finished run only - 200, or the 4xx a node rejected with. What a node throws, and the exceptions of the engine itself (ConstructionException, UnroutedStatusException, LoopLimitExceededException), leave run() unchanged; the door maps them.
ProcessOutcome (src/Data/ProcessOutcome.php): status(): ResponseStatus, response(): object, errors(): list<string>, events(): list<object>, messageKey(): ?string and context(): array (both carry the reject of a failed run) and isFailed(): bool (status 400 and above) and withResponse(object): self (a copy with another answer, all other fields unchanged). ProcessOutcome implements JardisSupport\Contract\Kernel\AnswerableOutcomeInterface (an OutcomeInterface - status(), isFailed() - plus the five accessors above), so a consumer such as the transaction bracket of jardissupport/repository recognises a failed run, and the answer closures of jardissupport/response build the answer from it, without knowing this package and without comparing status numbers. Existing methods are unchanged.
ProcessContext
Read-only view of one run, handed to every node:
| Method | Returns |
|---|---|
input() |
The input object |
previous() |
?NodeResult of the node run last |
latest(string $node) |
?NodeResult, most recent result of that node |
all(string $node) |
list<NodeResult> of that node in execution order |
iteration(string $node) |
How often that node has run so far (0 on the first pass) |
chain() |
list<array{node, result}>, the whole chain in order |
depth() |
Nesting depth of the run (0 = outermost) |
The chain is immutable: the engine hands every step a new context (withStep(), internal).
Inheritance is a documented exception. ProcessContext is deliberately not final: a generated process extends it with typed getters over the chain (see CheckoutContext above) and names it with context(). The class must extend ProcessContext, otherwise the run throws a ConstructionException. This is the one exception to composition over inheritance in the package; everything else is final.
NodeResult
NodeResult (src/Data/NodeResult.php) is immutable:
| Factory | Meaning |
|---|---|
NodeResult::ok(array|object $data) |
Success, takes Route::OnTrue with data |
NodeResult::route(Route $route, mixed $data = null) |
Take a named route; null data means a result without data - nothing is handed on from the previous step |
NodeResult::reject(ResponseStatus $status, string $messageKey, array $context = [], array $errors = []) |
End the run with a 4xx; any other status throws ConstructionException |
->withEvents(object ...$events) |
New result carrying the events (replaces earlier ones) |
Readers: takenRoute() (null exactly for a reject), data(), isRejected(), status(), messageKey(), context(), errors(), events().
Call nodes. MapResponse turns the answer of a called domain operation (a DomainResponseInterface - an aggregate command, or the inner door of a sub-process) into a node result: 2xx becomes ok(data) carrying the Domain events of the callee (the contract keys them by scope, they travel on flattened; Internal events stay behind - they are the write gesture of the callee's aggregate), 4xx a reject with the same status whose context is the plain context of the callee, every other status throws: a 5xx a CalleeFailureException (a \RuntimeException carrying the marker ReportedFailureExceptionInterface - the inner door has already reported the cause, so the outer door does not log it again), any other status without a node meaning (302, 418) a plain \RuntimeException. The form of the callee's data is unpacked, so the process answers the same form again (no form inside a form):
| Callee status | Reject context | Reject messageKey |
|---|---|---|
| 400 | fields |
'' |
| 404 | the inner context |
'' |
| 409 | reason, aggregate, violated, plus the inner context |
'' |
| 422 | rule, plus the inner context |
the callee's messageKey |
| other 4xx (401, 403, ...) | the data unchanged, a messageKey in it stays where it is |
'' |
@type is dropped. The message key travels as the reject key only for 422; for 400, 404 and 409 the key is left to the node's declaration (rejects()), and a status without a form passes its data through unchanged. The form's own fields win over an inner context key of the same name. With $onReject ((new MapResponse())($response, Route::OnFalse)) a 4xx becomes a route instead: the dev decision to carry on rather than refuse; a 5xx throws either way, as a CalleeFailureException.
$result = (new MapResponse())($notFoundResponse); // 404 with data ['@type' => 'notFound', 'context' => ['sku' => 'Z-9']] $result->isRejected(); // true $result->status(); // ResponseStatus::NotFound $result->messageKey(); // '' $result->context(); // ['sku' => 'Z-9']
Sub-process nodes
A node runs an inner process one level deeper and maps the answer of the inner door - a DomainResponseInterface - with MapResponse: events travel up, an inner 4xx is rejected again with the same status, or routed with $onReject. The engine knows no sub-process: the inner run is a run() with depth + 1, the door around it builds the answer.
final class ReleaseBatch { public function __construct(private InnerDoor $innerDoor) { } public function __invoke(ProcessContext $context): NodeResult { // $innerDoor runs the inner process with $context->depth() + 1 and answers its DomainResponseInterface return (new MapResponse())($this->innerDoor->answer($context), Route::OnFalse); } }
Rule nodes
A rule node is a normal node that rejects with ResponseStatus::RuleViolation (422) or goes on. ruleMode() decides how the process takes a violation:
RuleMode::Guard(default): the reject ends the run, the outcome carries 422RuleMode::Decision: a 422 becomesRoute::OnFalseand the process goes on there; every other result passes unchanged
final class ReviewOrder { public function __invoke(ProcessContext $context): NodeResult { return NodeResult::ok(['status' => 'review']); } } $decision = ProcessDefinition::for(PlaceOrder::class, 'PlaceOrderReviewed') ->ruleMode(CheckCredit::class, RuleMode::Decision) ->node(CheckCredit::class)->onTrue(ReserveStock::class)->onFalse(ReviewOrder::class) ->node(ReserveStock::class)->terminal() ->node(ReviewOrder::class)->terminal() ->build(); $outcome = $runner->run($decision, new PlaceOrder('ACME', 500), null, static fn(string $class): object => match ($class) { CheckCredit::class => new CheckCredit(), ReserveStock::class => new ReserveStock($store), ReviewOrder::class => new ReviewOrder(), }); // ResponseStatus::Success, response {status: 'review'}
Answer of a run
The form of the response of a successful run is declared per process with answer(Answer); the status is 200 and errors() is empty.
Answer |
Response |
|---|---|
LastNode (default) |
The data of the last step. A last step without data - an event node, a rule node that only lets the run go on (NodeResult::route(Route::OnTrue)) - answers an empty object. Nothing is handed on from earlier steps |
Merged |
The data of all executed steps united in run order; on the same field name the later step wins. Loop passes count as steps, in run order. A step without data (null, []) contributes no field; an object contributes its public properties (get_object_vars); a non-empty list (['a', 'b']) throws a ConstructionException - the union needs fields by name |
$merged = ProcessDefinition::for(stdClass::class, 'Merged') ->answer(Answer::Merged) ->node('Merged\Find')->onTrue('Merged\Reserve') ->node('Merged\Reserve')->terminal() ->build(); $outcome = $runner->run($merged, new stdClass(), null, static fn(string $class): object => match ($class) { 'Merged\Find' => static fn(ProcessContext $c): NodeResult => NodeResult::ok(['id' => 7, 'state' => 'found']), 'Merged\Reserve' => static fn(ProcessContext $c): NodeResult => NodeResult::ok(['state' => 'reserved']), }); // response {id: 7, state: 'reserved'}
Typed process answer. answerResponse(class, fields...) (only with Merged; build() throws a ConstructionException for LastNode, in any call order) projects the union onto the constructor fields of class (none named: all; fields not named fall away). The answer then no longer depends on the kind or the data of the last node - a sub-process node that hands on a foreign answer, or a node without data, leaves the fields of the earlier steps intact. A required field the union lacks, or an unknown class, throws a ConstructionException. A response() class on a node that ends the run is not applied in this case (the process answer is not the class of its last node); the response() classes of the other nodes stay. Without answerResponse() the union is returned as it is.
$definition = ProcessDefinition::for(CreateQuote::class, 'CreateQuote') ->answer(Answer::Merged) ->answerResponse(QuoteAnswer::class, 'quoteNumber', 'status') // QuoteAnswer(string $quoteNumber, string $status) ->node(Create::class)->onTrue(PriceCheck::class) ->node(PriceCheck::class)->terminal() // a sub-process with a foreign answer ->build(); // response QuoteAnswer {quoteNumber, status}: later wins, everything else falls away
A node that needs the answer of an earlier node reads it explicitly through ProcessContext::latest($node) / all($node) (the typed getters of a generated context do exactly that); previous() is the result of the node run last, which can be a node without data. A reject is unaffected by the answer form: it ends the run with its status, message key, context and errors.
Reject in the outcome. A rejected run answers status() = the 4xx, an empty response(), errors() of the reject, no events, messageKey() and context(). The message key is the one the node declared for that status with rejects(status, key), else the one the reject names. A 400 reject of a node fed by one input property (input('customer')) answers its field paths below that property (customer.<field>). Turning the outcome into a failure form (ValidationFailure, NotFound, Conflict, RuleViolation) is the business of the door.
Events
Events travel with the node result (->withEvents(...)); the package never sends them anywhere.
- A successful run reports the events of all steps in execution order (
$outcome->events()), including those a sub-process node passes up (MapResponsecarries them). - A rejected run reports no events.
- The caller publishes after the commit. The engine opens no transaction; the door commits, then publishes:
$outcome = $runner->run($definition, new PlaceOrder('ACME', 2), $nodeFactory); $published = []; foreach ($outcome->events() as $event) { $published[] = $event; // with a PSR-14 dispatcher: $dispatcher->dispatch($event); }
Loops
A route may point back to an earlier node. iteration($node) counts the passes; the way out is an explicit route (usually onExit). loopLimit() caps the node steps of one run (default 100); the step that would exceed the limit never executes, run() throws a LoopLimitExceededException (getLimit()). The engine answers no 500: the door maps the exception. A sub-process counts its steps on its own.
final class Poll { public function __invoke(ProcessContext $context): NodeResult { $pass = $context->iteration(self::class) + 1; return $pass >= 3 ? NodeResult::route(Route::OnExit, ['polls' => $pass]) : NodeResult::route(Route::OnFalse, ['polls' => $pass]); } } $poll = static fn(int $limit): ProcessDefinition => ProcessDefinition::for(stdClass::class, 'Poll') ->loopLimit($limit) ->node(Poll::class)->onFalse(Poll::class)->onExit(null) ->build(); $pollFactory = static fn(string $class): object => new Poll(); $outcome = $runner->run($poll(10), new stdClass(), $pollFactory); // ResponseStatus::Success, response {polls: 3} $runner->run($poll(2), new stdClass(), $pollFactory); // throws LoopLimitExceededException
Layer rules
- The application layer builds the definition and starts the runner; the domain core never imports workflow classes.
- Nodes stay thin and delegate to domain services. A node does not publish events and does not dispatch.
- The package publishes nothing, dispatches nothing and opens no transaction; the door owns validation, transaction, failure forms, logging and the events of the outcome.
Documentation
Full documentation, guides, and API reference:
docs.jardis.io/en/support/workflow
License
This package is licensed under the MIT License.
Jardis · Documentation · Headgent
AI-Assisted Development
This package ships with a skill for Claude Code, Cursor, Continue, and Aider. Install it in your consuming project:
composer require --dev jardis/dev-skills
More details: https://docs.jardis.io/en/skills