Skip to content

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 KonstantenON_SUCCESS, ON_FAIL, ON_TIMEOUT, ON_SKIP, ON_CANCEL, ON_EVENT, ON_EXIT
  • Typisierter Execution-ContextWorkflowContext trä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

bash
composer require jardissupport/workflow

GitHub: jardisSupport/workflow

Grundlegende Nutzung

Einen Workflow definieren und ausführen

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

Handler schreiben

Jeder Handler ist eine Klasse mit __invoke(). Er bekommt genau ein Argument: den WorkflowContextInterface. Rückgabewert ist ein 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' => '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 $data als Payload: der Handler liest ihn dann via $this->payload(). Ohne Factory wird new $class() aufgerufen und $data ignoriert.

WorkflowResult — Status und Daten

php
// 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:

KonstanteWertTypischer 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 null zeigt
  • 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:

php
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:

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

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(),
        ]);
    }
}

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.

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)                // Endknoten (keine Transitions)
    ->node(StepError::class)
    ->node(StepTimeout::class)
    ->build();

Der erste node()-Aufruf definiert den Einstiegspunkt des Workflows.

Array-API (ohne 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);

Ohne Factory wird new $class() verwendet; $data wird in diesem Fall ignoriert.

Initialer Input

Workflow::__invoke() nimmt ein einzelnes optionales $data-Argument entgegen:

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

php
// 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:

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

SituationException
Handler-Klasse existiert nichtInvalidArgumentException
Handler ist nicht callableInvalidArgumentException
Handler gibt kein WorkflowResultInterface zurückInvalidArgumentException
Ungültiger Status-StringInvalidArgumentException
Builder ohne NodesInvalidArgumentException

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 Transitions

Verzeichnisstruktur

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

API-Referenz

Workflow

MethodeSignaturBeschreibung
__construct__construct(?Closure $handlerFactory = null)Optionale Handler-Factory
__invoke__invoke(WorkflowConfigInterface $config, mixed $data = null): WorkflowContextInterfaceWorkflow ausführen

WorkflowContext

MethodeSignaturBeschreibung
appendappend(string $handlerFqcn, WorkflowResultInterface $result): voidErgebnis an Chain anhängen (intern durch Engine)
getPreviousgetPrevious(): ?WorkflowResultInterfaceErgebnis des unmittelbar vorigen Handlers
getLatestgetLatest(string $handlerFqcn): ?WorkflowResultInterfaceJüngstes Ergebnis eines bestimmten Handlers
getAllgetAll(string $handlerFqcn): list<WorkflowResultInterface>Alle Ergebnisse eines Handlers in Reihenfolge
getChaingetChain(): list<array{handler, result}>Vollständiges Ausführungs-Log

WorkflowResult

MethodeSignaturBeschreibung
getStatusgetStatus(): stringStatus (einer der ON_*-Werte)
getDatagetData(): mixedDaten-Payload
getHandlerFqcngetHandlerFqcn(): ?stringFQCN des Handlers (wird von der Engine via withHandler() gesetzt)

WorkflowBuilder

MethodeSignaturBeschreibung
nodenode(string $handlerClass): WorkflowNodeBuilderInterfaceNode definieren
addTransitionaddTransition(string $name, string $handlerClass): voidTransition zum aktuellen Node ergänzen
buildbuild(): WorkflowConfigInterfaceConfig erstellen

WorkflowNodeBuilder

MethodeSignaturBeschreibung
onSuccessonSuccess(string $handler): selfErfolg-Transition
onFailonFail(string $handler): selfFehler-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): WorkflowNodeBuilderInterfaceNächsten Node starten (delegiert an Builder)
buildbuild(): WorkflowConfigInterfaceWorkflow-Konfiguration abschliessen (delegiert an Builder)

Vollständiges Beispiel

Bestellprozess mit Validierung, Zahlung, Bestätigung und Fehlerbehandlung:

php
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');
}