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}Node or {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