Workflow
Gerichtete Workflow-Engine mit Status-basiertem Routing, typisiertem Execution-Context und Builder-API.
Einführung
Multi-Step-Prozesse in PHP (Bestellungen validieren, Zahlungen verarbeiten, Bestätigungen senden) enden oft als verschachtelte if-Ketten oder State-Machine-Frameworks mit hunderten Klassen. Wer eine simple, gerichtete Pipeline braucht, bei der jeder Schritt den nächsten bestimmt, steht vor zu viel oder zu wenig Abstraktion.
jardissupport/workflow ist ein Handler-Graph-Engine: Man definiert einen Graphen aus invokablen PHP-Klassen, verbindet sie über statusbasierte Transitions, und die Engine läuft den Graphen ab:
- Gerichteter Graph — jeder Handler gibt einen Status zurück, der den nächsten Handler bestimmt
- 7 Konstanten —
ON_SUCCESS,ON_FAIL,ON_TIMEOUT,ON_SKIP,ON_CANCEL,ON_EVENT,ON_EXIT - Typisierter Execution-Context —
WorkflowContextträgt jeden Handler-Aufruf als Eintrag im geordneten Ausführungs-Log;getPrevious()liefert das Ergebnis des Vorgängers, ohne dass der Handler dessen Klassennamen kennen muss - Verlustfreie History — Wiederholungen desselben Handlers (Retry-Schleifen, Cross-Branch-Revisits) werden angehängt statt überschrieben;
getAll(Foo::class)liefert alle Aufrufe,getLatest(Foo::class)den jüngsten - Fluenter Builder —
->node(Handler::class)->onSuccess(Next::class)->onFail(Error::class) - DI-Container-ready — optionale Factory-Closure für Handler-Instanziierung
- Kein Framework — Handler sind plain PHP Invokables mit
__invoke()
Installation
composer require jardissupport/workflowGitHub: jardisSupport/workflow
Grundlegende Nutzung
Einen Workflow definieren und ausführen
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 des zuletzt ausgeführten Handlers
$context->getLatest(ChargePaymentHandler::class); // jüngster Aufruf eines bestimmten Handlers
$context->getAll(ChargePaymentHandler::class); // alle Aufrufe in Reihenfolge (z.B. Retry-Versuche)
$context->getChain(); // vollständiges Ausführungs-LogHandler schreiben
Jeder Handler ist eine Klasse mit __invoke(). Er bekommt genau ein Argument: den WorkflowContextInterface. Rückgabewert ist ein 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' => 'Ungültiger Bestellwert',
]);
}
return new WorkflowResult(WorkflowResult::ON_SUCCESS, [
'validated' => true,
'tax' => $this->order->getTotal() * 0.19,
]);
}
}Wie kommt das Domain-Objekt in den Handler? Der initiale
$data-Parameter von$workflow($config, $order)wird ausschließlich an die handlerFactory weitergereicht. Eine typische Factory in einem BoundedContext spawnt den Handler als BC mit$dataals Payload: der Handler liest ihn dann via$this->payload(). Ohne Factory wirdnew $class()aufgerufen und$dataignoriert.
WorkflowResult — Status und Daten
// Erfolg mit Daten
new WorkflowResult(WorkflowResult::ON_SUCCESS, ['key' => 'value']);
// Fachlicher Misserfolg
new WorkflowResult(WorkflowResult::ON_FAIL, ['error' => 'Payment declined']);
// Timeout-Pfad
new WorkflowResult(WorkflowResult::ON_TIMEOUT, ['after' => '30s']);
// Abbruch (Stornierung)
new WorkflowResult(WorkflowResult::ON_CANCEL, ['reason' => 'user cancelled']);Status und Transitions
Alle Konstanten
Jede Konstante ist gleichzeitig Status und Transition-Schlüssel:
| Konstante | Wert | Typischer Einsatz |
|---|---|---|
ON_SUCCESS | 'onSuccess' | Erfolgreicher Abschluss |
ON_FAIL | 'onFail' | Fachlicher Misserfolg (Validierung, Geschäftsregel) |
ON_TIMEOUT | 'onTimeout' | Service-Side-Timeout in fachliches Routing übersetzt |
ON_SKIP | 'onSkip' | Handler nicht anwendbar — Flow überspringt weiter |
ON_CANCEL | 'onCancel' | Fachlicher Abbruch (Stornierung, Zustimmung zurückgezogen) |
ON_EVENT | 'onEvent' | Async-Hand-off via DomainEvent |
ON_EXIT | 'onExit' | Schleife/Block terminiert — weiter im umgebenden Flow |
Workflow-Ende
Der Workflow endet, wenn:
- Keine Transition für den zurückgegebenen Status konfiguriert ist
- Die Transition auf
nullzeigt - Der letzte Handler keine weiteren Transitions hat
WorkflowContext — typisierter Execution-Context
Der WorkflowContext trägt die gesamte Ausführungs-History als flaches, geordnetes Array von WorkflowResult-Einträgen (Execution Chain). Er wird vor dem ersten Handler erzeugt, an jeden Handler als letztes Argument durchgereicht und nach Workflow-Ende an den Aufrufer zurückgegeben.
Handler liest Vorgänger-Daten
Der einfachste Fall: der nachfolgende Handler will das Ergebnis des direkten Vorgängers:
final class ChargePaymentHandler
{
public function __construct(private readonly Order $order) {}
public function __invoke(WorkflowContextInterface $context): WorkflowResult
{
// Daten des direkt vorangegangenen Handlers
$previous = $context->getPrevious()?->getData() ?? [];
$tax = $previous['tax'] ?? 0;
$paymentId = $this->gateway->charge($this->order->getTotal() + $tax);
return new WorkflowResult(WorkflowResult::ON_SUCCESS, [
'paymentId' => $paymentId,
]);
}
}Gezielter Lookup eines vorherigen Handlers
Wenn ein Handler weiter hinten in der Kette ein Resultat von weiter vorne braucht, fragt er es per FQCN ab: kein Datenklumpen muss durch die Kette gereicht werden:
$validation = $context->getLatest(ValidateOrderHandler::class)?->getData();
$total = $validation['total'] ?? 0;Retry-Versuche zählen
Bei einem Self-Loop wird jeder Aufruf als eigener Eintrag angehängt: die History bleibt komplett:
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(),
]);
}
}Vollständiges Ausführungs-Log
getChain() liefert die geordnete Liste aller Aufrufe: jeder Eintrag enthält Handler-FQCN und das produzierte WorkflowResult. Der gleiche Handler kann mehrfach erscheinen.
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) // Endknoten (keine Transitions)
->node(StepError::class)
->node(StepTimeout::class)
->build();Der erste node()-Aufruf definiert den Einstiegspunkt des Workflows.
Array-API (ohne 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);Ohne Factory wird new $class() verwendet; $data wird in diesem Fall ignoriert.
Initialer Input
Workflow::__invoke() nimmt ein einzelnes optionales $data-Argument entgegen:
$context = $workflow($config, $order);$data wird ausschließlich an die handlerFactory weitergereicht, nicht positional an den Handler. Typisches Muster in einem BoundedContext: die Factory spawnt jeden Handler als eigenen BC mit $data als Payload; der Handler liest ihn via $this->payload(). Ohne Factory wird new $class() verwendet und $data ignoriert: der Handler arbeitet dann ausschließlich mit dem, was bereits im Context steht.
// Factory-Muster: $data als BC-Payload
$workflow = new Workflow(
fn(string $class, mixed $data) => $this->context($class, $data)
);
$context = $workflow($config, $order);
// Handler: public function __invoke(WorkflowContextInterface $context) — liest $order via $this->payload()Rückgabewert
Workflow::__invoke() gibt den WorkflowContextInterface zurück:
$context = $workflow($config, $order);
// Resultat des letzten ausgeführten Handlers
$final = $context->getPrevious();
// WorkflowResult(status: 'success', data: ['confirmed' => true, 'transactionId' => 'TX-1'])
// Vollständiges Ausführungs-Log — gleiche Handler können mehrfach vorkommen
$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', ...),
// ]Fehlerbehandlung
| Situation | Exception |
|---|---|
| Handler-Klasse existiert nicht | InvalidArgumentException |
| Handler ist nicht callable | InvalidArgumentException |
Handler gibt kein WorkflowResultInterface zurück | InvalidArgumentException |
| Ungültiger Status-String | InvalidArgumentException |
| Builder ohne Nodes | InvalidArgumentException |
Architektur
Workflow ← Orchestrator (Engine)
├── WorkflowConfig ← Graph-Definition (Nodes + Transitions)
├── WorkflowContext ← Execution-Log (Append-only Chain)
├── WorkflowResult ← Handler-Rückgabewert (Status + Daten)
└── Builder/
├── WorkflowBuilder ← Fluente Graph-Definition
└── WorkflowNodeBuilder ← Per-Node TransitionsVerzeichnisstruktur
src/
├── Workflow.php ← Orchestrator
├── WorkflowConfig.php ← Graph-Konfiguration
├── WorkflowContext.php ← Execution-Log
├── WorkflowResult.php ← Value Object (Status + Daten)
└── Builder/
├── WorkflowBuilder.php ← Fluenter Builder
└── WorkflowNodeBuilder.php ← Node-KonfigurationAPI-Referenz
Workflow
| Methode | Signatur | Beschreibung |
|---|---|---|
__construct | __construct(?Closure $handlerFactory = null) | Optionale Handler-Factory |
__invoke | __invoke(WorkflowConfigInterface $config, mixed $data = null): WorkflowContextInterface | Workflow ausführen |
WorkflowContext
| Methode | Signatur | Beschreibung |
|---|---|---|
append | append(string $handlerFqcn, WorkflowResultInterface $result): void | Ergebnis an Chain anhängen (intern durch Engine) |
getPrevious | getPrevious(): ?WorkflowResultInterface | Ergebnis des unmittelbar vorigen Handlers |
getLatest | getLatest(string $handlerFqcn): ?WorkflowResultInterface | Jüngstes Ergebnis eines bestimmten Handlers |
getAll | getAll(string $handlerFqcn): list<WorkflowResultInterface> | Alle Ergebnisse eines Handlers in Reihenfolge |
getChain | getChain(): list<array{handler, result}> | Vollständiges Ausführungs-Log |
WorkflowResult
| Methode | Signatur | Beschreibung |
|---|---|---|
getStatus | getStatus(): string | Status (einer der ON_*-Werte) |
getData | getData(): mixed | Daten-Payload |
getHandlerFqcn | getHandlerFqcn(): ?string | FQCN des Handlers (wird von der Engine via withHandler() gesetzt) |
WorkflowBuilder
| Methode | Signatur | Beschreibung |
|---|---|---|
node | node(string $handlerClass): WorkflowNodeBuilderInterface | Node definieren |
addTransition | addTransition(string $name, string $handlerClass): void | Transition zum aktuellen Node ergänzen |
build | build(): WorkflowConfigInterface | Config erstellen |
WorkflowNodeBuilder
| Methode | Signatur | Beschreibung |
|---|---|---|
onSuccess | onSuccess(string $handler): self | Erfolg-Transition |
onFail | onFail(string $handler): self | Fehler-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 | Nächsten Node starten (delegiert an Builder) |
build | build(): WorkflowConfigInterface | Workflow-Konfiguration abschliessen (delegiert an Builder) |
Vollständiges Beispiel
Bestellprozess mit Validierung, Zahlung, Bestätigung und Fehlerbehandlung:
use JardisSupport\Contract\Workflow\WorkflowContextInterface;
use JardisSupport\Workflow\Workflow;
use JardisSupport\Workflow\WorkflowResult;
use JardisSupport\Workflow\Builder\WorkflowBuilder;
// Handler definieren — jeder Handler bekommt ausschließlich WorkflowContextInterface.
// Der initiale $order-Input wird via Factory als BC-Payload übergeben;
// Handler mit Payload-Bedarf lesen ihn 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' => 'Bestellung enthält keine Artikel',
]);
}
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,
]);
}
}
// Graph konfigurieren
$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();
// Ausführen — $order wird an die Factory weitergereicht, nicht an den 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 "Bestellung bestätigt. Transaction: " . $final->getData()['transactionId'];
} else {
$data = $final?->getData() ?? [];
echo "Bestellung fehlgeschlagen: " . ($data['error'] ?? $data['declineReason'] ?? 'unbekannt');
}