Extending Workflows
Any module can extend the Workflows system by registering custom nodes. This allows you to create domain-specific triggers and actions that integrate with your module's functionality.
Architecture Overview
┌────────────────────────────────────────────────────────────────────────┐
│ Workflows Module │
│ ┌──────────────────────────────────────────────────────────────────┐│
│ │ NodeRegistry ││
│ │ - workflows:manual-trigger ││
│ │ - workflows:send-email ││
│ │ - workflows:http-request ││
│ │ - ... ││
│ └──────────────────────────────────────────────────────────────────┘│
└────────────────────────────────────────────────────────────────────────┘
▲
│ register()
│
┌───────────────────────────────────┴────────────────────────────────────┐
│ Your Module │
│ ┌──────────────────────────────────────────────────────────────────┐│
│ │ OrdersServiceProvider ││
│ │ - registerWorkflowNodes() ││
│ │ └─► NodeRegistry::registerMany([ ││
│ │ OrderCreatedTrigger::class, ││
│ │ UpdateOrderStatusAction::class, ││
│ │ ]) ││
│ └──────────────────────────────────────────────────────────────────┘│
│ ┌──────────────────────────────────────────────────────────────────┐│
│ │ Nodes/ ││
│ │ Triggers/ ││
│ │ - OrderCreatedTrigger.php ││
│ │ - OrderStatusChangedTrigger.php ││
│ │ Actions/ ││
│ │ - UpdateOrderStatusAction.php ││
│ └──────────────────────────────────────────────────────────────────┘│
└────────────────────────────────────────────────────────────────────────┘
Step 1: Create the Node Contract
All nodes must implement WorkflowNodeContract:
<?php
namespace Modules\Workflows\Contracts;
interface WorkflowNodeContract
{
// Unique identifier: "module:node-name"
public static function getIdentifier(): string;
// Display name
public static function getName(): string;
// Category: trigger, action, condition, transformer
public static function getCategory(): string;
// Description for UI
public static function getDescription(): string;
// Lucide icon name
public static function getIcon(): string;
// Node color (hex)
public static function getColor(): string;
// Module alias
public static function getModule(): string;
// Input port definitions
public static function getInputs(): array;
// Output port definitions
public static function getOutputs(): array;
// JSON Schema for configuration
public static function getConfigSchema(): array;
// Default configuration values
public static function getDefaultConfig(): array;
// Validate configuration
public function validateConfig(array $config): array;
// Execute the node
public function execute(array $input, array $config, array $context): array;
// Check compatibility with other nodes
public static function isCompatibleWith(string $targetNodeType): bool;
}
Step 2: Create Your Node Class
Using BaseNode (Recommended)
Extend BaseNode for common functionality:
<?php
namespace Modules\Orders\Nodes\Triggers;
use Modules\Workflows\Nodes\BaseNode;
use Modules\Orders\App\Models\Order;
class OrderCreatedTrigger extends BaseNode
{
public static function getIdentifier(): string
{
return 'orders:order-created';
}
public static function getName(): string
{
return 'Order Created';
}
public static function getCategory(): string
{
return 'trigger';
}
public static function getDescription(): string
{
return 'Triggers when a new order is created';
}
public static function getIcon(): string
{
return 'ShoppingCart';
}
public static function getColor(): string
{
return '#22C55E'; // Green for triggers
}
public static function getModule(): string
{
return 'orders';
}
public static function getInputs(): array
{
// Triggers have no inputs
return [];
}
public static function getOutputs(): array
{
return [
[
'name' => 'output',
'label' => 'Order Data',
'type' => 'object',
],
];
}
public static function getConfigSchema(): array
{
return [
'type' => 'object',
'properties' => [
'orderStatuses' => [
'type' => 'array',
'title' => 'Order Statuses',
'description' => 'Only trigger for these statuses (empty = all)',
'items' => ['type' => 'string'],
],
],
];
}
public static function getDefaultConfig(): array
{
return [
'orderStatuses' => [],
];
}
public function validateConfig(array $config): array
{
// No required config for this trigger
return [];
}
public function execute(array $input, array $config, array $context): array
{
// For event triggers, $input contains the event payload
$orderId = $input['order_id'] ?? null;
if (!$orderId) {
throw new \Exception('Order ID is required');
}
// Load the order
$order = Order::with(['customer', 'items'])->find($orderId);
if (!$order) {
throw new \Exception("Order not found: {$orderId}");
}
// Check status filter
$statusFilter = $config['orderStatuses'] ?? [];
if (!empty($statusFilter) && !in_array($order->status, $statusFilter)) {
throw new \Exception("Order status '{$order->status}' not in allowed list");
}
// Return order data for next nodes
return [
'order' => [
'id' => $order->id,
'status' => $order->status,
'total' => $order->total,
'currency' => $order->currency,
'created_at' => $order->created_at->toISOString(),
],
'customer' => [
'id' => $order->customer->id,
'email' => $order->customer->email,
'name' => $order->customer->name,
'phone' => $order->customer->phone,
],
'items' => $order->items->map(fn($item) => [
'product_id' => $item->product_id,
'name' => $item->name,
'quantity' => $item->quantity,
'price' => $item->price,
])->toArray(),
];
}
}
Creating an Action Node
<?php
namespace Modules\Orders\Nodes\Actions;
use Modules\Workflows\Nodes\BaseNode;
use Modules\Orders\App\Models\Order;
class UpdateOrderStatusAction extends BaseNode
{
public static function getIdentifier(): string
{
return 'orders:update-order-status';
}
public static function getName(): string
{
return 'Update Order Status';
}
public static function getCategory(): string
{
return 'action';
}
public static function getDescription(): string
{
return 'Update the status of an order';
}
public static function getIcon(): string
{
return 'RefreshCw';
}
public static function getColor(): string
{
return '#3B82F6'; // Blue for actions
}
public static function getModule(): string
{
return 'orders';
}
public static function getInputs(): array
{
return [
[
'name' => 'input',
'label' => 'Input',
'type' => 'object',
'required' => true,
],
];
}
public static function getOutputs(): array
{
return [
[
'name' => 'output',
'label' => 'Output',
'type' => 'object',
],
];
}
public static function getConfigSchema(): array
{
return [
'type' => 'object',
'properties' => [
'orderId' => [
'type' => 'string',
'title' => 'Order ID',
'description' => 'Expression to get order ID (e.g., {{order.id}})',
],
'newStatus' => [
'type' => 'string',
'title' => 'New Status',
'enum' => ['pending', 'processing', 'shipped', 'delivered', 'cancelled'],
],
'notifyCustomer' => [
'type' => 'boolean',
'title' => 'Notify Customer',
'default' => true,
],
],
'required' => ['orderId', 'newStatus'],
];
}
public static function getDefaultConfig(): array
{
return [
'notifyCustomer' => true,
];
}
public function validateConfig(array $config): array
{
$errors = [];
if (empty($config['orderId'])) {
$errors[] = 'Order ID is required';
}
if (empty($config['newStatus'])) {
$errors[] = 'New status is required';
}
return $errors;
}
public function execute(array $input, array $config, array $context): array
{
// Resolve expressions in config
$orderId = $this->resolveExpression($config['orderId'], $input);
$newStatus = $config['newStatus'];
$notifyCustomer = $config['notifyCustomer'] ?? true;
// Find and update the order
$order = Order::findOrFail($orderId);
$oldStatus = $order->status;
$order->update([
'status' => $newStatus,
]);
// Optionally notify customer
if ($notifyCustomer) {
// Dispatch notification job
// NotifyCustomerOrderStatus::dispatch($order);
}
// Return what this node produced — not a copy of $input.
// See "Output Conventions" for why, and for how a downstream node
// still reaches the original payload via {{trigger_data.*}}.
return [
'_order_updated' => true,
'_old_status' => $oldStatus,
'_new_status' => $newStatus,
'_updated_at' => now()->toISOString(),
'order' => $order->fresh()->toArray(),
];
}
}
Creating a Condition Node
<?php
namespace Modules\Orders\Nodes\Conditions;
use Modules\Workflows\Nodes\BaseNode;
class OrderValueCondition extends BaseNode
{
public static function getIdentifier(): string
{
return 'orders:order-value-check';
}
public static function getName(): string
{
return 'Order Value Check';
}
public static function getCategory(): string
{
return 'condition';
}
public static function getDescription(): string
{
return 'Branch based on order total value';
}
public static function getIcon(): string
{
return 'DollarSign';
}
public static function getColor(): string
{
return '#F59E0B'; // Yellow for conditions
}
public static function getModule(): string
{
return 'orders';
}
public static function getInputs(): array
{
return [
['name' => 'input', 'label' => 'Input', 'type' => 'object', 'required' => true],
];
}
public static function getOutputs(): array
{
return [
['name' => 'high', 'label' => 'High Value', 'type' => 'object', 'conditional' => true],
['name' => 'low', 'label' => 'Low Value', 'type' => 'object', 'conditional' => true],
];
}
public static function getConfigSchema(): array
{
return [
'type' => 'object',
'properties' => [
'threshold' => [
'type' => 'number',
'title' => 'Threshold Amount',
'description' => 'Orders above this are "high value"',
'default' => 100,
],
'valuePath' => [
'type' => 'string',
'title' => 'Value Path',
'description' => 'Path to order total (e.g., order.total)',
'default' => 'order.total',
],
],
'required' => ['threshold'],
];
}
public static function getDefaultConfig(): array
{
return [
'threshold' => 100,
'valuePath' => 'order.total',
];
}
public function execute(array $input, array $config, array $context): array
{
$threshold = $config['threshold'] ?? 100;
$valuePath = $config['valuePath'] ?? 'order.total';
// Get the value using dot notation
$value = data_get($input, $valuePath, 0);
$isHighValue = $value >= $threshold;
// `_branch` tells the engine which outgoing edges may fire. Return it
// alongside this node's own findings rather than merged into a copy
// of $input.
return [
'_branch' => $isHighValue ? 'high' : 'low',
'_order_value' => $value,
'_threshold' => $threshold,
'_is_high_value' => $isHighValue,
];
}
}
Step 3: Register Nodes in ServiceProvider
<?php
namespace Modules\Orders;
use Illuminate\Support\ServiceProvider;
use Modules\Workflows\App\Services\NodeRegistry;
class OrdersServiceProvider extends ServiceProvider
{
public function boot(): void
{
// ... other boot logic
$this->registerWorkflowNodes();
}
protected function registerWorkflowNodes(): void
{
// Check if Workflows module is installed
if (!class_exists(NodeRegistry::class)) {
return;
}
$registry = $this->app->make(NodeRegistry::class);
$registry->registerMany([
// Triggers
\Modules\Orders\Nodes\Triggers\OrderCreatedTrigger::class,
\Modules\Orders\Nodes\Triggers\OrderStatusChangedTrigger::class,
// Actions
\Modules\Orders\Nodes\Actions\UpdateOrderStatusAction::class,
\Modules\Orders\Nodes\Actions\CreateOrderNoteAction::class,
// Conditions
\Modules\Orders\Nodes\Conditions\OrderValueCondition::class,
]);
}
}
Step 4: Fire Events for Triggers
For event-based triggers, fire workflow events from your module:
<?php
namespace Modules\Orders\App\Observers;
use Modules\Orders\App\Models\Order;
use Modules\Workflows\App\Services\WorkflowService;
class OrderObserver
{
protected WorkflowService $workflowService;
public function __construct(WorkflowService $workflowService)
{
$this->workflowService = $workflowService;
}
public function created(Order $order): void
{
// Trigger workflows that use orders:order-created
$this->workflowService->triggerByEvent('orders:order-created', [
'order_id' => $order->id,
]);
}
public function updated(Order $order): void
{
if ($order->wasChanged('status')) {
// Trigger workflows that use orders:order-status-changed
$this->workflowService->triggerByEvent('orders:order-status-changed', [
'order_id' => $order->id,
'old_status' => $order->getOriginal('status'),
'new_status' => $order->status,
]);
}
}
}
Register the observer:
// In OrdersServiceProvider::boot()
Order::observe(OrderObserver::class);
BaseNode Helper Methods
The BaseNode class provides useful helper methods:
Expression Resolution
// Resolve {{expressions}} in strings
$email = $this->resolveExpression('{{customer.email}}', $input);
// Resolve in arrays recursively
$config = $this->resolveExpressions($config, $input);
Data Access
// Get nested value with dot notation
$total = data_get($input, 'order.total', 0);
// Set nested value
data_set($output, 'result.success', true);
Validation Helpers
public function validateConfig(array $config): array
{
$errors = [];
// Required field
if (empty($config['field'])) {
$errors[] = 'Field is required';
}
// Type check
if (isset($config['amount']) && !is_numeric($config['amount'])) {
$errors[] = 'Amount must be a number';
}
// Enum check
$validStatuses = ['pending', 'active', 'closed'];
if (!in_array($config['status'] ?? '', $validStatuses)) {
$errors[] = 'Invalid status';
}
return $errors;
}
Config Schema Reference
Use JSON Schema for configuration:
Basic Types
public static function getConfigSchema(): array
{
return [
'type' => 'object',
'properties' => [
// String
'name' => [
'type' => 'string',
'title' => 'Name',
'description' => 'Enter a name',
'minLength' => 1,
'maxLength' => 255,
],
// Number
'amount' => [
'type' => 'number',
'title' => 'Amount',
'minimum' => 0,
'maximum' => 10000,
],
// Integer
'count' => [
'type' => 'integer',
'title' => 'Count',
'default' => 1,
],
// Boolean
'enabled' => [
'type' => 'boolean',
'title' => 'Enabled',
'default' => true,
],
// Enum (dropdown)
'priority' => [
'type' => 'string',
'title' => 'Priority',
'enum' => ['low', 'medium', 'high'],
'default' => 'medium',
],
// Textarea
'description' => [
'type' => 'string',
'title' => 'Description',
'format' => 'textarea',
],
// Array
'tags' => [
'type' => 'array',
'title' => 'Tags',
'items' => ['type' => 'string'],
],
// Object
'settings' => [
'type' => 'object',
'title' => 'Settings',
'properties' => [
'key' => ['type' => 'string'],
],
],
],
'required' => ['name', 'amount'],
];
}
How a Schema Becomes a Form
You never write frontend code for a node's configuration. The builder renders the form from your schema, which is the whole point of the node contract: a module contributes nodes without touching the Workflows module or the Next.js app.
NodeRegistry runs every schema through NodeSchemaNormalizer and serves the
result as config_schema alongside the raw configSchema. The panel renders
config_schema.
Two accepted formats
JSON Schema — preferred. Use this for new nodes. It is also what validation, API consumers and external tooling read.
return [
'type' => 'object',
'properties' => [
'url' => ['type' => 'string', 'title' => 'URL'],
],
'required' => ['url'],
];
Flat field list — still supported. Several existing nodes (all of AI's, all of Orders') declare fields directly. These keep working and will not be removed — a module installed from the marketplace cannot be rewritten because the platform tightened a contract.
return [
['key' => 'url', 'type' => 'text', 'label' => 'URL', 'required' => true],
];
Prefer JSON Schema in new code. Both normalise to the same thing.
Widget selection
For JSON Schema, the control is inferred:
| Schema | Control |
|---|---|
enum present |
select |
type: boolean |
switch |
type: integer / number |
number input |
type: object / array |
JSON editor |
format: email / url / password / textarea / expression |
that control |
| anything else | text input |
An unknown control degrades to a text input rather than rendering nothing — a node should never be silently unconfigurable.
x-ui — presentation hints
Anything the type system cannot express goes in x-ui on the property. It is
ignored by JSON Schema validators, so the schema stays valid.
'body' => [
'type' => 'object',
'title' => 'Request Body',
'x-ui' => [
'widget' => 'json', // force a control
'group' => 'Advanced', // section heading in the panel
'order' => 10, // explicit position; default is declaration order
'placeholder' => '{"key": "value"}',
'showIf' => ['field' => 'method', 'in' => ['POST', 'PUT', 'PATCH']],
],
],
| Key | Purpose |
|---|---|
widget |
Force a control instead of the inferred one |
group |
Group fields under a heading |
order |
Explicit ordering; otherwise declaration order is kept |
placeholder |
Input placeholder |
label |
Override the label when title is not suitable |
enumLabels |
Map enum values to friendly labels |
showIf |
Conditional visibility |
required |
Mark required without listing it in required (useful with showIf) |
patternMessage |
Message shown when pattern fails |
Conditional fields
showIf takes one of three shapes:
'showIf' => ['field' => 'method', 'in' => ['POST', 'PUT']] // value is one of
'showIf' => ['field' => 'mode', 'equals' => 'advanced'] // value equals
'showIf' => ['field' => 'add_note'] // value is truthy
Two behaviours matter:
- A hidden field is not validated. Requiring a value behind a condition the user cannot see would be unfixable from the builder.
- A hidden field's value is dropped on save. Switching an HTTP node from POST back to GET does not leave a stale body behind.
showIf must name a sibling field in the same node. A dangling reference means the
field can never appear, and NodeRegistrySchemaTest fails the build for it.
Validation
POST /workflow-nodes/{identifier}/validate checks the config against your schema
before anything is saved:
- required fields (skipping hidden ones)
enummembershipminimum/maximum, andminLength/maxLengthpattern, withx-ui.patternMessageas the message
false and 0 count as answers, not omissions.
Your node's own validateConfig() runs afterwards for anything a schema cannot
express — checking that a credential resolves, say. Return a flat list of messages,
or key them by field to have the builder highlight the right input:
public function validateConfig(array $config): array
{
if (($config['provider'] ?? null) && !$this->providerExists($config['provider'])) {
return ['provider' => ['That provider is not configured.']];
}
return [];
}
The response carries errors (flat, for existing callers) and field_errors
(keyed by field, used to highlight inputs).
Simulation (Dry Run)
POST /workflows/{id}/simulate walks a workflow and reports what would happen.
It writes nothing — no execution row, no logs, no counters — and it runs only the
steps that declare themselves safe.
Not to be confused with
POST /workflows/{id}/test, which executes for real under atestlabel. That one genuinely sends the email.
Your node is stubbed by default
public static function isSimulationSafe(): bool
{
return false; // the default, inherited from BaseNode
}
A stubbed node is not executed; its input is passed through so the walk continues
and downstream steps are still reported. The default is false on purpose: a node
that sends mail, charges a card or calls a paid API must not fire because someone
pressed Simulate, and inheriting the safe answer costs nothing.
Opt in only when execute() is pure — reads its input, computes, returns:
/**
* Reshapes the payload in memory; reaches nothing outside the run.
*/
public static function isSimulationSafe(): bool
{
return true;
}
When real behaviour is safe but unhelpful
Override simulate(). The Delay node is the clearest case — correct to model,
pointless to actually wait for:
public function simulate(array $input, array $config, array $context): array
{
return array_merge($input, [
'_delay_seconds' => $this->secondsFrom($config),
'_simulated_wait' => true,
]);
}
$context['simulation'] is true during a dry run, if a node needs to branch on it.
What the report contains
| Key | Meaning |
|---|---|
ok |
Whether the workflow could run at all |
errors |
Blocking problems — no entry point, a loop, an edge to a missing node |
warnings |
Per-step config problems, prefixed with the step label |
steps |
Ordered trace: index, label, node_type, input, config, mode, reason |
unreached |
Nodes the run would never arrive at — usually an orphan on the canvas |
variables |
Workflow variables as they stood at the end |
executed / stubbed |
How many steps ran versus were skipped |
Each step's mode is executed, stubbed or error. Config is validated as part
of the walk, so a required field left blank surfaces before the workflow is turned
on rather than on a queue worker hours later.
Checklist for a new node
- Field keys are unique within the node
- Every field has a label, or a key that humanises well (
order_id→ "Order Id") - Every
selecthas options — an empty dropdown cannot be completed, so the normaliser degrades it to a text input - Keys in
getDefaultConfig()correspond to real fields - Any
showIfnames a field that exists
NodeRegistrySchemaTest asserts all of these against the live registry, so a new
node in any module is covered as soon as it is registered.
Testing Your Nodes
Unit Test
<?php
namespace Modules\Orders\Tests\Unit\Nodes;
use Tests\TestCase;
use Modules\Orders\Nodes\Actions\UpdateOrderStatusAction;
use Modules\Orders\App\Models\Order;
class UpdateOrderStatusActionTest extends TestCase
{
public function test_updates_order_status()
{
$order = Order::factory()->create(['status' => 'pending']);
$node = new UpdateOrderStatusAction();
$result = $node->execute(
['order' => ['id' => $order->id]],
['orderId' => '{{order.id}}', 'newStatus' => 'processing'],
[]
);
$this->assertEquals('processing', $order->fresh()->status);
$this->assertTrue($result['_order_updated']);
$this->assertEquals('pending', $result['_old_status']);
$this->assertEquals('processing', $result['_new_status']);
}
public function test_validates_required_config()
{
$node = new UpdateOrderStatusAction();
$errors = $node->validateConfig([]);
$this->assertContains('Order ID is required', $errors);
$this->assertContains('New status is required', $errors);
}
}
Integration Test
public function test_workflow_with_custom_node()
{
$workflow = Workflow::factory()
->withNodes([
['node_type' => 'orders:order-created'],
['node_type' => 'orders:update-order-status', 'config' => [
'orderId' => '{{order.id}}',
'newStatus' => 'processing',
]],
])
->create();
$order = Order::factory()->create();
$engine = app(ExecutionEngine::class);
$execution = $engine->execute($workflow, ['order_id' => $order->id]);
$this->assertEquals('completed', $execution->status);
$this->assertEquals('processing', $order->fresh()->status);
}
Best Practices
Naming Conventions
- Identifier:
module-alias:node-name(e.g.,orders:order-created) - Class name:
{Action}Nodeor{Event}Trigger(e.g.,SendEmailNode,OrderCreatedTrigger) - File location:
Nodes/{Category}/{ClassName}.php
Error Handling
public function execute(array $input, array $config, array $context): array
{
try {
// Your logic
} catch (ModelNotFoundException $e) {
throw new \Exception("Order not found: {$orderId}");
} catch (\Exception $e) {
// Log and rethrow with user-friendly message
Log::error('Node execution failed', [
'node' => static::getIdentifier(),
'error' => $e->getMessage(),
]);
throw new \Exception("Failed to update order: {$e->getMessage()}");
}
}
Output Conventions
Return what your node produced, and prefix its metadata with an underscore:
return [
// Prefix metadata with underscore
'_action_performed' => true,
'_performed_at' => now()->toISOString(),
// The data this node produced
'order' => $updatedOrder,
];
Do not echo $input back out. return array_merge($input, [...]) makes every
node's output the sum of everything upstream of it. That payload is stored twice
per node in workflow_execution_logs (input_data and output_data) and
re-encoded into the run's checkpoint after every step, so the cost is paid once
per remaining node rather than once. A single HTTP node returning a large response
body attaches that body to the run for the rest of its length.
Some built-in nodes still echo — HttpRequestNode, SendEmailNode, IfElseNode
and others — because saved workflows resolve bare {{order.total}}-style
expressions against the accumulated payload, and taking that away would break them
silently. New nodes should not add to it.
Upstream data is reachable without echoing. The engine merges the execution
context into the data your {{expressions}} resolve against:
// In BaseNode-derived nodes:
$data = array_merge($input, $context);
$to = $this->resolveExpression($config['to'], $data);
$context carries trigger_data (the payload the run started with, in full),
variables (everything Set Variable has set), and workflow_id /
execution_id. Both trigger_data and variables are persisted with the
execution and restored when a paused run resumes, so:
{{trigger_data.order.email}}resolves at any depth in the graph{{variables.discount_code}}resolves wherever it was set{{order.email}}resolves only if every node in between echoed it forward
Prefer the first two. For data your own node computed, name it in the output and have the next node read it from there.
Joins and _branches
A node with more than one inbound connection receives the branches merged, with
later branches winning on key collisions, plus a _branches key holding each
inbound payload separately for nodes that need to tell them apart.
_branches is for the joining node only. The engine removes it from that node's
output before scheduling successors, the same way it removes _pause_seconds —
so do not expect it downstream, and do not copy it into your own output.
One place it does survive is the node's own row in workflow_execution_logs.
That row is written from the raw return value, before the engine strips its own
keys, so output_data for a join still shows _branches — useful when
debugging, and the same has always been true of _pause_seconds. The execution
result never carries it, on a run that finishes in one go or one that is
resumed and rebuilds the node's output from that log row.
Documentation
Document your nodes in your module's README:
## Workflow Nodes
This module provides the following workflow nodes:
### Triggers
- **Order Created** (`orders:order-created`) - Fires when new order is created
- **Order Status Changed** (`orders:order-status-changed`) - Fires on status change
### Actions
- **Update Order Status** (`orders:update-order-status`) - Change order status
Next Steps
- Node Types - Built-in node reference
- Execution Engine - How nodes are executed
- Examples - Complete workflow examples