adachsoft / workflow
Lightweight, extensible PHP workflow engine with plugin-based steps, context isolation, debugging and nested workflow support
Requires
- php: ^8.2
- adachsoft/collection: ^3.0
Requires (Dev)
- adachsoft/php-code-style: ^0.4.2
- friendsofphp/php-cs-fixer: ^3.94
- phpstan/phpstan: ^2.1
- phpunit/phpunit: ^13.0
- rector/rector: ^2.3
Suggests
None
Provides
None
Conflicts
None
Replaces
None
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:
WorkflowDefinitionRepositoryInterfaceWorkflowRunRepositoryInterfaceStepRegistryInterface
Available engines:
WorkflowEngineInterfacefor synchronous workflow executionDebugEngineInterfacefor step-by-step workflow debugging
You can build ready-to-use in-memory engines with:
WorkflowEngineBuilderDebugEngineBuilder
Static data-flow validation (without executing steps) is available through:
WorkflowFlowValidatorInterfaceWorkflowFlowValidatorBuilder
Main Concepts
Step (branch-first)
A workflow step implements StepInterface and exposes metadata through #[StepType]:
inputSchema- expected step inputconfigSchema- expected step configbranches- 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
PayloadVoentries - an explicit workflow-level
input_schema - an optional workflow-level
output_schemathat 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_mapcopies selected step output fields into contextinput_mapbuilds the next step input from context (and literals)config_mapcan 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 workflowinput_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_schemais declared
Validation errors raise SchemaValidationException.
Branch contract violations can raise:
UnknownBranchExceptionBranchTargetMissingException
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 declaresoutput_schemaSleepStep- 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), ornullwhen 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