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.