Packstub

Extending

Every trigger, action and condition is a small PHP class. The built-in ones in Packstub\Flow\Nodes\Triggers, Actions and Conditions are written exactly the way yours will be, so they are the best examples to read.

Anatomy of a node

All nodes extend Packstub\Flow\Nodes\Node through one of three base classes and share these methods:

Method Required Purpose
getName(): string yes Shown in the sidebar, as the default node label, and as the slide-over heading
getDescription(): string no One line under the name in the sidebar and slide-over
getIcon(): ?string no A Heroicon name (heroicon-o-bolt) or raw <svg> markup, shown in the sidebar
getCategory(): string no The sidebar group: triggers, actions or conditions after the base class, or a group of your own (crm, billing); see Sidebar groups
getFormSchema(): array no Filament form components for the node's settings. The values are stored on the node as config and passed to matches() / handle() / evaluate()
getPlaceholders(): array no ['{{ model.name }}' => 'The record name', ...], listed in the slide-over's Placeholders section
isAvailable(): bool (static) no Return false when a package the node needs is missing; the node is then never registered nor offered (the spatie state triggers work this way)
getOutputs(): array no The output handles drawn on the canvas, ['output' => 'Next'] by default; the runner follows the edges leaving the handle a node picks (['body' => 'Each item', 'done' => 'Done'])

Nodes are resolved from the container (Node::make() is app(static::class)), so constructor injection works. Names and descriptions are usually translation strings, but plain text is fine.

The config array is exactly what the form produced; the payload array is what the trigger provided (see Placeholders).

Actions

Extend Packstub\Flow\Nodes\Action and implement handle(array $config, array $payload): void. Throw to fail the run (or let the node's retry / continue-on-error settings deal with it); return to continue. Call $this->output([...]) before returning to expose values to the nodes after it as {{ last.* }} and {{ outputs.<node id>.* }} — see Placeholders.

namespace App\Flow\Actions;

use Filament\Forms\Components\Select;
use Packstub\Flow\Nodes\Action;
use Packstub\Flow\Nodes\Concerns\InterpolatesPlaceholders;

class AssignToTeam extends Action
{
    use InterpolatesPlaceholders;

    public function getName(): string
    {
        return 'Assign to team';
    }

    public function getDescription(): string
    {
        return 'Moves the record to a team.';
    }

    public function getIcon(): ?string
    {
        return 'heroicon-o-user-group';
    }

    public function getFormSchema(): array
    {
        return [
            Select::make('team_id')
                ->label('Team')
                ->options(fn (): array => Team::query()->pluck('name', 'id')->all())
                ->searchable()
                ->required(),
        ];
    }

    public function handle(array $config, array $payload): void
    {
        $record = $payload['model'] ?? null;

        if (! $record) {
            throw new \RuntimeException('Assign to team needs a record; use it after a record trigger.');
        }

        $record->forceFill(['team_id' => $config['team_id']])->saveQuietly();

        $this->output(['team_id' => $config['team_id']]);
    }
}

Inside handle(), $this->output([...]) exposes values to the rest of the branch as {{ last.* }}; pass a second array to store a shorter summary on the step log ($this->output(['records' => $records], ['count' => $records->count()])). $this->setPayloadValue('model', $record) replaces a top-level payload key for the nodes after this one. Override preview(array $config, array $payload): array to control what a test run shows instead of running the action, and implement Packstub\Flow\Contracts\ReadOnlyAction (a marker interface) when the action changes nothing, so a test run executes it for real.

Delayable actions

An action that implements Packstub\Flow\Contracts\Delayable pauses the run instead of running: the runner calls getDelaySeconds($config, $payload) and, for a positive number, schedules the nodes after it through the queue exactly like the built-in Wait (see Queue & scheduling). handle() is never called in that case; null or 0 means "continue now", and then handle() runs as for any other action. Use it for a "wait until the record's due date" kind of step:

use Packstub\Flow\Contracts\Delayable;

class WaitUntilDue extends Action implements Delayable
{
    public function getDelaySeconds(array $config, array $payload): ?int
    {
        $due = $payload['model']?->due_at;

        return $due?->isFuture() ? (int) now()->diffInSeconds($due) : null;
    }

    public function handle(array $config, array $payload): void
    {
        // never called when a delay was returned
    }
}

Loops, waits and polled triggers

Three more contracts let an action or a trigger take part in the runner's control flow:

Contract Implement The runner
Contracts\Iterates getItems($config, $payload): iterable, getItemKey($config), getMaxIterations($config) Visits the body output once per item with the item (and loop) in the payload, then follows done. Give the node getOutputs() returning ['body' => ..., 'done' => ...]
Contracts\Waitable createWait($config, $payload): ?WaitRequest, afterWaitCreated(WorkflowWait $wait, $config, $payload) Stores a WorkflowWait with the branch payload and a graph snapshot, stops the branch, and continues along the outcome's handle when Flow::resolveWait() / Flow::signal() is called or the timeout passes. WaitRequest names the type (approval / event), the outcomes, a timeout, an optional key and metadata for the Approvals page
Contracts\Pollable (triggers) poll($config, DateTimeInterface $now): iterable of payloads Asks every active trigger of that type each minute from packstub-flow:cron and starts a run per payload, honouring once

The built-in For each, Ask for approval, Wait for signal and Date on a record are the reference implementations.

Actions that pick the branch

An action can be a branch of its own: implement Contracts\PicksOutput, list the outputs, and name the one to follow from handle() with continueAlong(). The run then follows only the edges leaving that handle (none connected ends the path).

use Packstub\Flow\Contracts\PicksOutput;
use Packstub\Flow\Nodes\Action;

class RouteByRegion extends Action implements PicksOutput
{
    public function getOutputs(): array
    {
        return ['eu' => 'EU', 'us' => 'US', 'other' => 'Elsewhere'];
    }

    public function handle(array $config, array $payload): void
    {
        $region = Regions::of($payload['model']);

        $this->output(['region' => $region]);
        $this->continueAlong(in_array($region, ['eu', 'us'], true) ? $region : 'other');
    }

    // Followed when the action failed and the node is set to "log it and continue"; null ends the path.
    public function outputOnFailure(array $config): ?string
    {
        return 'other';
    }
}

A test run does not run the action, so it follows the first output.

When the outputs depend on the node's settings, return them from getOutputsFor(array $config) as well (getOutputs() stays what a newly dropped node shows). The canvas draws them when it opens and whenever the settings are applied, and drops the edges of a branch that no longer exists; they are worked out from the settings each time, never stored in the definition. The built-in Decide action is the reference: one output per option.

Conditions

Extend Packstub\Flow\Nodes\Condition and implement evaluate(array $config, array $payload): bool. true follows the True output, false the False output.

namespace App\Flow\Conditions;

use Filament\Forms\Components\TextInput;
use Packstub\Flow\Nodes\Condition;

class IsVip extends Condition
{
    public function getName(): string
    {
        return 'Customer is VIP';
    }

    public function getIcon(): ?string
    {
        return 'heroicon-o-star';
    }

    public function getFormSchema(): array
    {
        return [
            TextInput::make('minimum_orders')->numeric()->default(10)->required(),
        ];
    }

    public function evaluate(array $config, array $payload): bool
    {
        $customer = $payload['model']?->customer;

        return $customer && $customer->orders()->count() >= (int) $config['minimum_orders'];
    }
}

The Packstub\Flow\Nodes\Conditions\Concerns\ComparesValues trait used by the built-in conditions gives you the operator select ($this->operatorSelect()), the comparison ($this->compare($actual, $operator, $expected)) and static::needsValue($operator) for hiding the value field, if your condition compares things.

Triggers

Extend Packstub\Flow\Nodes\Trigger. Override matches(array $config, array $payload): bool to decide whether a dispatched payload should start the workflow this node belongs to — it is called once per active workflow that has the trigger, with that node's settings. The default returns true.

namespace App\Flow\Triggers;

use Filament\Forms\Components\Select;
use Packstub\Flow\Nodes\Trigger;

class OrderPaid extends Trigger
{
    public function getName(): string
    {
        return 'Order paid';
    }

    public function getDescription(): string
    {
        return 'Fires when a payment for an order succeeds.';
    }

    public function getIcon(): ?string
    {
        return 'heroicon-o-banknotes';
    }

    public function getFormSchema(): array
    {
        return [
            Select::make('gateway')
                ->options(['any' => 'Any', 'stripe' => 'Stripe', 'paddle' => 'Paddle'])
                ->default('any'),
        ];
    }

    public function matches(array $config, array $payload): bool
    {
        $gateway = $config['gateway'] ?? 'any';

        return $gateway === 'any' || ($payload['gateway'] ?? null) === $gateway;
    }

    public function getPlaceholders(): array
    {
        return [
            '{{ model.reference }}' => 'The paid order',
            '{{ gateway }}' => 'The payment gateway',
        ];
    }
}

Then fire it from wherever the thing happens — a listener, a controller, a job:

use Packstub\Flow\Facades\Flow;

Flow::dispatch(OrderPaid::class, ['model' => $order, 'gateway' => 'stripe']);

Put the record under model so Update record, Record attribute and {{ model.* }} / {{ record.* }} work as with the built-in record triggers. Flow::dispatch() returns the runs that finished synchronously.

Placeholders in your own nodes

Add the Packstub\Flow\Nodes\Concerns\InterpolatesPlaceholders trait for two helpers and a default placeholder list:

$this->interpolate($config['subject'] ?? '', $payload);          // string with {{ }} filled in
$this->interpolateArray($config['headers'] ?? [], $payload);     // every string in a nested array

The trait's getPlaceholders() returns the generic examples ({{ model.name }}, {{ model.team.name }}, {{ webhook.order.id }}, {{ event.order.total }}); override it to document what your node reads. Packstub\Flow\Support\Placeholders has the static equivalents (render, renderArray, resolve, stringify).

Registering nodes

Three ways, all ending up in the same Packstub\Flow\NodeRegistry singleton — only registered classes are ever instantiated from a stored workflow:

// 1. On the plugin
FlowPlugin::make()
    ->triggers([OrderPaid::class])
    ->actions([AssignToTeam::class])
    ->conditions([IsVip::class])

// 2. In config/packstub-flow.php, next to the built-in ones
'actions' => [
    Nodes\Actions\SendEmail::class,
    // ...
    App\Flow\Actions\AssignToTeam::class,
],

// 3. Anywhere, e.g. a service provider's boot()
use Packstub\Flow\Facades\Flow;

Flow::register(AssignToTeam::class);   // sorted into triggers / actions / conditions by base class

Sidebar groups

The sidebar lists Triggers, Actions and Conditions, and any other group a node asks for through getCategory() — the built-in Ask AI lives in AI this way, and a group only shows while a node is offered in it. Group your own nodes when a dozen of them would otherwise stretch the Actions list:

public function getCategory(): string
{
    return 'crm';
}

The group's name and the line under it come from the builder translations, packstub-flow::flow.builder.crm and builder.crm_description, so add them to lang/vendor/packstub-flow/en/flow.php in your app (a group without a translation shows its key capitalised). The base class still decides how the node behaves and connects — a CRM action is drawn and run as an action — and the category is never stored in a workflow, so renaming a group later touches nothing saved. Nodes in AI take a teal look on the canvas; other custom groups keep their type's colours.

Hide built-in nodes with FlowPlugin::make()->without([...]) or by removing them from the config lists. A node that is no longer registered disappears from the sidebar, and a saved workflow that still uses it fails its runs with "is not registered" — so clean up workflows before removing a node they depend on. The registry is application-wide: nodes registered for one panel are available in every panel.

Running workflows from code

use Packstub\Flow\Facades\Flow;

// Start every active workflow whose trigger of this type matches
$runs = Flow::dispatch(OrderPaid::class, ['model' => $order]);

// Run one workflow: from its first trigger, or from a given node, sync or queued
$run = Flow::run($workflow, ['model' => $order]);
$run = Flow::run($workflow, ['model' => $order], startNodeId: 'trigger-xyz', queue: false);

// Reach the registry
Flow::registry()->actions();
Flow::registry()->has(AssignToTeam::class);

// Run code without any trigger starting a workflow (imports, seeders)
Flow::suppress(fn () => Order::query()->update([...]));

// Dry run: side effects simulated, conditions evaluated
$run = Flow::test($workflow, ['model' => $order]);

// Continue runs parked on a "Wait for signal" / an approval
Flow::signal("payment.{$order->id}", ['amount' => 99]);
Flow::resolveWait($wait, 'approved', ['comment' => 'ok'], 'boss@example.com');
Flow::expireWaits();

// What packstub-flow:cron does for "Date on a record" triggers
Flow::poll(now());

// Multi-tenancy: which tenant a payload belongs to
Flow::resolveTenantUsing(fn (array $payload) => $payload['model']?->team);

Flow::run() returns null when the workflow is inactive, has no trigger node, or the run was queued; otherwise the finished WorkflowRun.

Pieces for your own pages

The create page is built from parts you can reuse:

use Packstub\Flow\Filament\Forms\Components\ChoiceCards;
use Packstub\Flow\Filament\Resources\WorkflowResource\Pages\CreateWorkflow;
use Packstub\Flow\Filament\Resources\WorkflowResource\Pages\ListWorkflows;

// A link to the create page with a way to start (and a template) picked
CreateWorkflow::startUrl('template', 'dunning'); // /admin/workflows/create?start=template&template=dunning
CreateWorkflow::starts();                          // the ways offered in this install

// The starters of the empty Workflows table, as link actions for your own page
ListWorkflows::starterActions();

// A radio drawn as cards with an icon, the label and its description
ChoiceCards::make('plan')
    ->options(['basic' => 'Basic', 'pro' => 'Pro'])
    ->descriptions(['basic' => 'For one team.', 'pro' => 'For the whole company.'])
    ->icons(['basic' => 'heroicon-o-user', 'pro' => 'heroicon-o-user-group'])
    ->badges(['pro' => 'New'])        // a short badge beside the label
    ->accents(['pro' => 'teal']);     // amber, blue, purple or teal, like the canvas nodes

Packstub\Flow\Support\DefinitionPreview::make($workflow->definition) lays a definition out for a static diagram — the nodes where they sit on the canvas, coloured by kind, and the edges between them; its marked argument takes node ids to badge, such as array_keys(DefinitionValidator::missingSettingsByNode($definition)) — and the packstub-flow::components.definition-preview view draws it as an SVG. Pass it preview, and optionally compact (no text, for a thumbnail), class and style; include it inside a .fi-flow wrapper so its styles apply:

<div class="fi-flow">
    @include('packstub-flow::components.definition-preview', [
        'preview' => \Packstub\Flow\Support\DefinitionPreview::make($workflow->definition),
        'class' => 'h-40 w-full',
    ])
</div>

Next: Configuration.