Search by

adachsoft / workflow

Arkadiusz Adach

Lightweight, extensible PHP workflow engine with plugin-based steps, context isolation, debugging and nested workflow support

Package info

gitlab.com/a.adach/Workflow

Issues

pkg:composer/adachsoft/workflow

Statistics

Installs: 49

Dependents: 2

Suggesters: 0

Stars: 0

v5.2.0 2026-10-29 09:18 UTC

README

Simple, plugin-based workflow engine for PHP with branch-first step contracts, context-based data flow, and built-in payload schema validation.

Requirements

  • PHP 8.2+
  • adachsoft/collection ^3.0

Installation

composer require adachsoft/workflow

Overview

The library provides two engines built around three central contracts:

  • WorkflowDefinitionRepositoryInterface
  • WorkflowRunRepositoryInterface
  • StepRegistryInterface

Available engines:

  • WorkflowEngineInterface for synchronous workflow execution
  • DebugEngineInterface for step-by-step workflow debugging

You can build ready-to-use in-memory engines with:

  • WorkflowEngineBuilder
  • DebugEngineBuilder

Static data-flow validation (without executing steps) is available through:

  • WorkflowFlowValidatorInterface
  • WorkflowFlowValidatorBuilder

Main Concepts

Step (branch-first)

A workflow step implements StepInterface and exposes metadata through #[StepType]:

  • inputSchema - expected step input
  • configSchema - expected step config
  • branches - declared execution branches and output schemas

A step returns StepResultDto::branch('<branch_name>', $output).

The engine resolves the next workflow step from current step config[<branch_name>] (after config_map resolution) for non-terminal branches.

Workflow definition

A workflow definition is represented by WorkflowDefinitionVo and stores:

  • named workflow steps as PayloadVo entries
  • an explicit workflow-level input_schema
  • an optional workflow-level output_schema that validates the final output of every terminal branch

The first defined key is treated as the entry step.

Workflow run

A workflow run is represented by WorkflowRunVo and stores:

  • current step
  • input
  • output
  • finished state
  • per-run context (WorkflowRunContextVo)

Payload

PayloadVo is an immutable value object used for step input, step config, and step output.

Data flow between steps

A step returns only its own output. The engine never forwards that output as the next step input.

The only carrier of data between steps is run context, declared in the workflow definition:

  • start() writes the workflow payload into run context (not into the first step input)
  • output_map copies selected step output fields into context
  • input_map builds the next step input from context (and literals)
  • config_map can overlay step config from context, including branch targets

Every step that needs data — including the first step — must declare input_map.

Example definition with input_schema, input_map, and output_map:

use AdachSoft\Workflow\Engine\PayloadVo;
use AdachSoft\Workflow\Engine\WorkflowDefinitionVo;
use AdachSoft\Workflow\Enum\SchemaPhaseEnum;
use AdachSoft\Workflow\Schema\PayloadSchemaVo;

$definitionRepository->save(new WorkflowDefinitionVo('example', [
    'append' => new PayloadVo([
        'type' => 'append_message',
        'config' => new PayloadVo([
            'message' => 'processed',
            'on_next' => 'finish',
        ]),
        'input_map' => new PayloadVo([
            'messages' => '$context.messages',
        ]),
        'output_map' => new PayloadVo([
            'messages' => '$context.messages',
        ]),
    ]),
    'finish' => new PayloadVo([
        'type' => 'finish',
        'config' => PayloadVo::empty(),
        'input_map' => new PayloadVo([
            'messages' => '$context.messages',
        ]),
    ]),
], PayloadSchemaVo::fromArray([
    'messages' => [
        'type' => 'array',
        'required' => true,
        'description' => 'Initial messages',
    ],
], SchemaPhaseEnum::Input)));

Schema Validation

WorkflowEngine and DebugEngine validate payloads in these phases:

  • Workflow input (start() payload vs workflow input_schema) - strict mode (unknown keys are rejected)
  • Step input (before execution) - permissive mode (extra keys allowed)
  • Step config (before execution) - strict mode
  • Step output (after execution, per selected branch) - strict mode
  • Workflow output (after a terminal step) - strict mode when output_schema is declared

Validation errors raise SchemaValidationException.

Branch contract violations can raise:

  • UnknownBranchException
  • BranchTargetMissingException

Workflow output schema

WorkflowDefinitionVo accepts an optional outputSchema using the SchemaPhaseEnum::Output phase. When it is null, the workflow has no workflow-level output contract. An explicit empty output schema is different: it permits no output fields. At runtime, each terminal step result is validated strictly against this schema before the run is marked finished.

The static flow validator also checks that every reachable terminal branch declares the required workflow output fields with compatible types, requiredness, and nullability, and declares no extra fields.

Nested input_map references

input_map resolves $context.* references recursively inside nested objects and arrays, so it can build a structured payload for a sub-workflow:

{
  "input_map": {
    "payload": {
      "prompt": "$context.prompt",
      "models_config_file": "$context.models_config_file",
      "count": "$context.count"
    }
  }
}

Nested payload values keep their object or array representation while their references are resolved. A nested PayloadVo remains a PayloadVo. Missing or null references are omitted from objects and removed from lists (with list indexes re-numbered); nested empty objects remain present. Literal values are unchanged, associative-array keys are preserved, and this recursive behavior applies only to input_map, not config_map or output_map.

Config value resolvers

Applications can resolve string references in the top-level step configuration by implementing ConfigValueResolverInterface and passing the adapter to either engine builder. For example, a step configuration can contain "api_key": "env('NAME')". The workflow engine does not interpret the reference syntax or value source. Without an adapter, the default resolver leaves values unchanged. Resolution happens after config_map; nested configuration values are not resolved.

use AdachSoft\Workflow\Builder\WorkflowEngineBuilder;
use AdachSoft\Workflow\Contracts\ConfigValueResolverInterface;
use AdachSoft\Workflow\Exception\ConfigValueResolutionException;

final readonly class EnvironmentConfigValueResolver implements ConfigValueResolverInterface
{
    public function resolve(string $value): string
    {
        if (preg_match("/^env\\('([^']+)'\\)$/", $value, $matches) !== 1) {
            return $value;
        }

        $resolved = getenv($matches[1]);
        if ($resolved === false) {
            throw new ConfigValueResolutionException('Environment variable is unavailable');
        }

        return $resolved;
    }
}

$workflowEngine = WorkflowEngineBuilder::create()
    ->withConfigValueResolver(new EnvironmentConfigValueResolver())
    ->build();

Adapters must not include resolved values in exception messages.

Static flow validation

WorkflowFlowValidatorBuilder analyzes a workflow graph without executing steps. It reports missing required inputs, unknown $context. references, unreachable steps, unknown step types, terminal branch outputs that do not match the workflow output_schema, and related contract issues.

use AdachSoft\Workflow\Validation\WorkflowFlowValidatorBuilder;

$flowValidator = WorkflowFlowValidatorBuilder::create()
    ->withWorkflowDefinitionRepository($definitionRepository)
    ->withStepRegistry($stepRegistry)
    ->build();

$result = $flowValidator->validate('example');

if (!$result->valid) {
    foreach ($result->violations as $violation) {
        // $violation->stepName, $violation->code, $violation->message
    }
}

Basic Usage

use AdachSoft\Workflow\Attribute\StepType;
use AdachSoft\Workflow\Builder\WorkflowEngineBuilder;
use AdachSoft\Workflow\Contracts\StepInterface;
use AdachSoft\Workflow\Engine\PayloadVo;
use AdachSoft\Workflow\Engine\StepContextDto;
use AdachSoft\Workflow\Engine\StepResultDto;
use AdachSoft\Workflow\Engine\WorkflowDefinitionVo;
use AdachSoft\Workflow\Enum\SchemaPhaseEnum;
use AdachSoft\Workflow\Registry\InMemoryStepRegistry;
use AdachSoft\Workflow\Repository\InMemoryWorkflowDefinitionRepository;
use AdachSoft\Workflow\Repository\InMemoryWorkflowRunRepository;
use AdachSoft\Workflow\Schema\PayloadSchemaVo;
use AdachSoft\Workflow\Interaction\NoUserInteractionChannel;

#[StepType(
    type: 'append_message',
    description: 'Appends configured message',
    inputSchema: [
        'messages' => ['type' => 'array', 'required' => true, 'description' => 'Current messages'],
    ],
    configSchema: [
        'message' => ['type' => 'string', 'required' => true, 'description' => 'Message to append'],
        'on_next' => ['type' => 'string', 'required' => true, 'description' => 'Next step name'],
    ],
    branches: [
        'on_next' => [
            'description' => 'Continue workflow',
            'terminal' => false,
            'outputSchema' => [
                'messages' => ['type' => 'array', 'required' => true, 'description' => 'Updated messages'],
            ],
        ],
    ],
)]
final class AppendMessageStep implements StepInterface
{
    public function execute(StepContextDto $stepContextDto): StepResultDto
    {
        $messages = $stepContextDto->input->get('messages');
        $message = $stepContextDto->config->get('message');

        if (!is_array($messages) || !is_string($message)) {
            return StepResultDto::branch('on_next', new PayloadVo(['messages' => []]));
        }

        $messages[] = $message;

        return StepResultDto::branch('on_next', new PayloadVo([
            'messages' => $messages,
        ]));
    }
}

#[StepType(
    type: 'finish',
    description: 'Finishes workflow',
    inputSchema: [
        'messages' => ['type' => 'array', 'required' => true, 'description' => 'Final messages'],
    ],
    branches: [
        'end' => [
            'description' => 'Finish run',
            'terminal' => true,
            'outputSchema' => [
                'status' => ['type' => 'string', 'required' => true, 'description' => 'Final status'],
                'messages' => ['type' => 'array', 'required' => true, 'description' => 'Final messages'],
            ],
        ],
    ],
)]
final class FinishStep implements StepInterface
{
    public function execute(StepContextDto $stepContextDto): StepResultDto
    {
        return StepResultDto::branch('end', new PayloadVo([
            'status' => 'done',
            'messages' => $stepContextDto->input->get('messages'),
        ]));
    }
}

$stepRegistry = new InMemoryStepRegistry();
$stepRegistry->register(AppendMessageStep::class);
$stepRegistry->register(FinishStep::class);

$definitionRepository = new InMemoryWorkflowDefinitionRepository();
$runRepository = new InMemoryWorkflowRunRepository();

$workflowEngine = WorkflowEngineBuilder::create()
    ->withStepRegistry($stepRegistry)
    ->withWorkflowDefinitionRepository($definitionRepository)
    ->withWorkflowRunRepository($runRepository)
    ->build();

$definitionRepository->save(new WorkflowDefinitionVo('example', [
    'append' => new PayloadVo([
        'type' => 'append_message',
        'config' => new PayloadVo([
            'message' => 'processed',
            'on_next' => 'finish',
        ]),
        'input_map' => new PayloadVo([
            'messages' => '$context.messages',
        ]),
        'output_map' => new PayloadVo([
            'messages' => '$context.messages',
        ]),
    ]),
    'finish' => new PayloadVo([
        'type' => 'finish',
        'config' => PayloadVo::empty(),
        'input_map' => new PayloadVo([
            'messages' => '$context.messages',
        ]),
    ]),
], PayloadSchemaVo::fromArray([
    'messages' => ['type' => 'array', 'required' => true, 'description' => 'Initial messages'],
], SchemaPhaseEnum::Input)));

$runId = $workflowEngine->start('example', new PayloadVo([
    'messages' => ['created'],
]), new NoUserInteractionChannel());

$run = $runRepository->get($runId);

Builders

WorkflowEngineBuilder and DebugEngineBuilder provide two usage modes:

  • build a default in-memory engine
  • build an engine with custom repositories and a custom step registry

Example:

use AdachSoft\Workflow\Builder\DebugEngineBuilder;

$debugEngine = DebugEngineBuilder::create()->build();

Create engines only through builders. Do not instantiate WorkflowEngine, DebugEngine, or WorkflowFlowValidator directly.

Built-in Steps

The package currently provides these built-in steps in src/Steps:

  • RunWorkflowStep - starts another workflow and forwards its final output when the child declares output_schema
  • SleepStep - pauses execution for configured seconds

Sub-workflow output

RunWorkflowStep returns an empty on_next output when the child workflow has no output_schema, preserving the existing behavior. When config.workflow_id selects a child statically and that child declares output_schema, the effective on_next branch schema is derived from the child definition and the child's final output becomes the step output. The parent can then use output_map to pass those fields into its own context.

Selecting the child dynamically through config_map remains supported for children without output_schema. A dynamically selected child that declares output_schema is not supported in this release and fails strict branch-output validation with SchemaValidationException.

Builders can register built-in steps for you:

$workflowEngine = WorkflowEngineBuilder::create()
    ->withStandardSteps()
    ->build();

Debug Engine

DebugEngineInterface supports:

  • starting a workflow in paused mode
  • starting from a selected step
  • executing one step at a time
  • pausing and resuming a run
  • reading the current run state

startFrom() writes the supplied payload into run context and does not validate it against workflow input_schema.

After step() or resume() executes one step, the returned WorkflowRunVo exposes the latest execution through lastStepExecution. It contains the step name, resolved input, output, and selected branch. The property is null before a debug step has executed and represents only the latest step, not execution history.

$run = $debugEngine->step($runId, $interactionChannel);
$execution = $run->lastStepExecution;

if ($execution !== null) {
    $stepName = $execution->stepName;
    $input = $execution->input;
    $output = $execution->output;
    $branch = $execution->branch;
}

Interaction channel

InteractionChannelInterface is the required bidirectional port between interactive workflow steps and their users. Applications provide transport adapters, such as a console or a WebSocket connection; the library does not provide transport-specific adapters.

  • write($data) sends a raw data fragment without adding a newline or formatting it.
  • read($timeoutSeconds) waits for at most the given number of seconds and returns a data fragment (which may include a newline), or null when no data arrives or the channel is closed.
  • isClosed() reports whether the user can no longer send data, for example after EOF or disconnection.
  • isInteractive() reports whether an interactive user is available through the channel.

Pass a channel to WorkflowEngineInterface::start(). Steps implementing InteractiveStepInterface receive it through executeInteractive(StepContextDto $stepContextDto, InteractionChannelInterface $interactionChannel); ordinary steps continue to use StepInterface::execute(). For debug execution, pass a channel to DebugEngineInterface::step() or resume(). It applies to that call only, so different step executions can use different user sessions.

Use NoUserInteractionChannel when starting a workflow that has no interactive user. It ignores writes, returns null immediately from read(), and reports itself as closed and non-interactive. RunWorkflowStep uses this channel when called directly and forwards its parent's channel to nested workflows. The channel is not stored in WorkflowRunVo or persisted by the run repository.

Inside executeInteractive(), a step can communicate with the user like this:

$interactionChannel->write('Enter a value: ');
$userInput = $interactionChannel->read(30.0);

Step Metadata

The registry can expose all registered step definitions via StepRegistryInterface::getAllDefinitions().

This returns StepDefinitionCollection and can be serialized using collection export methods.

Design Notes

  • Engines depend on contracts for repositories and the step registry.
  • Workflow execution is synchronous.
  • Debug engine executes one step at a time.
  • Public API uses immutable value objects and collections.
  • Step metadata and branch contracts are centralized in #[StepType].
  • Input/config/output validation is integrated into runtime execution.
  • Data between steps travels only through run context and declared maps.

Development

Available Composer scripts:

composer test
composer phpstan
composer cs-fix
composer cs-check