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
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.

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

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.

Next: Configuration.