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)
$configarrives 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) andconnection(the node's connection settings).- You rarely need
$contextdirectly, but it's there for reads ($context->get(...)) and, for nodes that produce context variables, writes ($context->set(...)). - Return a
NodeResult.
NodeResult
| Constructor | Effect |
|---|---|
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
| Method | Default | Purpose |
|---|---|---|
advanced(): array | [] | Extra schema fields shown under "Advanced settings". |
cliSupported(): bool | false | Whether the node appears in workflow:build. Return true if it can be configured through linear prompts. |
version(): int | 1 | This 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.