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 updated data
return array_merge($input, [
'_order_updated' => true,
'_old_status' => $oldStatus,
'_new_status' => $newStatus,
'_updated_at' => now()->toISOString(),
'order' => array_merge($input['order'] ?? [], [
'status' => $newStatus,
]),
]);
}
}
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;
return array_merge($input, [
'_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'],
];
}
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
Always include metadata in output:
return array_merge($input, [
// Prefix metadata with underscore
'_action_performed' => true,
'_performed_at' => now()->toISOString(),
// Update relevant data
'order' => $updatedOrder,
]);
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