Skip to main content

Custom nodes

A node is a class extending Qanna\WorkflowEngine\Node with four static descriptors and a handle() method.

Generate one​

php artisan workflow:make-node SendSlackMessageNode --type=app::send-slack-message

This writes app/Workflows/Nodes/SendSlackMessageNode.php:

namespace App\Workflows\Nodes;

use Qanna\WorkflowEngine\Engine\Context\WorkflowContext;
use Qanna\WorkflowEngine\Engine\Schema\Schema;
use Qanna\WorkflowEngine\Node;
use Qanna\WorkflowEngine\NodeResult;

class SendSlackMessageNode extends Node
{
public static function type(): string
{
return 'app::send-slack-message';
}

public static function label(): string
{
return 'Send Slack Message';
}

public static function schema(): array
{
return [
Schema::text('channel')->required()->placeholder('#alerts'),
Schema::text('text')->required()->help('Supports {{ variables }}.'),
];
}

public function handle(WorkflowContext $context, array $config): NodeResult
{
// $config is already resolved: {{ }} expressions are replaced,
// defaults are... not — apply your own fallbacks.
$sent = $this->slack->send($config['channel'], $config['text']);

return NodeResult::success(['ts' => $sent->timestamp]);
}
}

handle(WorkflowContext $context, array $config)​

  • $config arrives already resolved — every {{ expression }} in the node's config has been evaluated against the current context. It also carries _node_id (this node's id) and connection (the node's connection settings).
  • You rarely need $context directly, but it's there for reads ($context->get(...)) and, for nodes that produce context variables, writes ($context->set(...)).
  • Return a NodeResult.

NodeResult​

ConstructorEffect
NodeResult::success($output = [], $branch = 'main')Node succeeded. $output becomes {{ nodes.<id>.* }} (non-arrays are wrapped as { result: ... }). $branch selects the outgoing edge.
NodeResult::fail($message)Deliberate failure — fails the execution with $message. Not retried.
NodeResult::suspend($token)Pause the workflow. See Suspend & resume.
NodeResult::stop($output = [])End the workflow now, as an intentional stop (Cancelled).

A thrown exception is caught by the engine, retried per the node's advanced settings, and then surfaced as an execution error.

Node state​

For nodes that suspend and resume, persist small amounts of state on $this. It's serialised across the suspend and restored before the resumed handle() call:

public function handle(WorkflowContext $context, array $config): NodeResult
{
if ($this->hasState('requested')) {
return NodeResult::success(['reply' => $context->get('resume.reply')]);
}

$this->remember('requested', true);

return NodeResult::suspend(ResumeToken::manual('awaiting reply'));
}

remember($k, $v), state($k, $default), hasState($k), forgetState($k). State values that implement StateSerializable (like the loop cursor) are rehydrated to their class on resume.

Branching nodes​

Implement DeclaresBranches::branches(): array to advertise your branch names to the builder and validator, and return the chosen branch from handle():

use Qanna\WorkflowEngine\Engine\Contracts\DeclaresBranches;

class RiskCheckNode extends Node implements DeclaresBranches
{
public static function branches(): array
{
return ['clear', 'review', 'block'];
}

public function handle(WorkflowContext $context, array $config): NodeResult
{
return NodeResult::success(['score' => $score], branch: $this->bucket($score));
}
}

If your node also groups a sub-run whose branch should fall through back to main when it's exhausted (like Scope or Loop do), additionally implement the marker interface TraversalAware.

Optional descriptors​

MethodDefaultPurpose
advanced(): array[]Extra schema fields shown under "Advanced settings".
cliSupported(): boolfalseWhether the node appears in workflow:build. Return true if it can be configured through linear prompts.
version(): int1This node's version within its type family. See Node versioning.

Registering the node​

Nodes must be registered on the NodeRegistry at boot, from a service provider:

use Qanna\WorkflowEngine\Engine\NodeRegistry;

public function boot(): void
{
$this->app->make(NodeRegistry::class)->register([
\App\Workflows\Nodes\SendSlackMessageNode::class,
], 'action'); // category — groups the node in the builder's palette
}

The second argument is the palette category (action, logic, model, or a category of your own). Register triggers with the 'trigger' category — see Custom triggers.