Packstub

Configuration

Panel-specific choices (nodes, models, navigation, the resource) live on the plugin; everything app-wide (tables, models, queue, limits, webhooks, retention) lives in config/packstub-flow.php.

The plugin API

use Packstub\Flow\FlowPlugin;
use Packstub\Flow\Nodes\Actions\SendSlackMessage;

FlowPlugin::make()
    // Nodes: add your own (appended to the config lists)
    ->triggers([App\Flow\Triggers\OrderPaid::class])
    ->actions([App\Flow\Actions\AssignToTeam::class])
    ->conditions([App\Flow\Conditions\IsVip::class])

    // Hide built-in nodes from the builder; runs that still use them fail
    ->without([SendSlackMessage::class])

    // Models offered by the record triggers, in addition to app/Models
    // classes using HasWorkflows and the config's models_for_triggers
    ->models([App\Models\Order::class, App\Models\Invoice::class])

    // Navigation of the Workflows resource (defaults come from the config)
    ->navigationGroup('Automation')
    ->navigationIcon('heroicon-o-bolt')
    ->navigationSort(10)

    // Replace the resource with your own subclass, or register none
    ->resource(App\Filament\Resources\WorkflowResource::class)
    ->withoutResource()

    // Same for the Secrets page
    ->secretResource(App\Filament\Resources\SecretResource::class)
    ->withoutSecrets()

    // The cross-workflow Runs page and the Approvals page
    ->withoutRunsPage()
    ->withoutApprovalsPage()

    // Multi-tenancy: which tenant a payload belongs to, and a plan limit
    ->resolveTenantUsing(fn (array $payload) => $payload['model']?->team)
    ->maxWorkflows(fn (?Team $team) => $team?->plan->workflows)
Method Default Notes
triggers(), actions(), conditions() [] Class names; duplicates are ignored
without() [] Removes classes from the registry, whatever list they came from
models() [] Class names of Eloquent models
navigationGroup() navigation.group (null)
navigationIcon() navigation.icon (heroicon-o-bolt)
navigationSort() navigation.sort (null)
resource() Packstub\Flow\Filament\Resources\WorkflowResource Extend the built-in resource to change the table, the form or the pages
withoutResource() resource registered Use FlowBuilder::make('definition') in a resource of your own
secretResource() Packstub\Flow\Filament\Resources\SecretResource The Secrets page
withoutSecrets() page registered Hide the Secrets page (placeholders keep working for secrets created in code)
withoutRunsPage() page registered Hide the Runs page
withoutApprovalsPage() page registered Hide the Approvals page (notification and email links keep working)
resolveTenantUsing() null See Multi-tenancy
maxWorkflows() null Workflows a tenant (or the app) may have; the Create button is disabled beyond it
templates() [] Files, directories or documents offered under Template on the create page; see Templates
withoutBuiltInTemplates() built-in offered Only your own templates

FlowPlugin::get() returns the plugin instance of the current panel. Nodes and models are registered in application-wide singletons, so a class added on one panel is known to all panels.

The config file

php artisan vendor:publish --tag="packstub-flow-config"

Tables and models

'tables' => [
    'workflows' => 'flow_workflows',
    'triggers' => 'flow_workflow_triggers',
    'runs' => 'flow_workflow_runs',
],

'models' => [
    'workflow' => Packstub\Flow\Models\Workflow::class,
    'trigger' => Packstub\Flow\Models\WorkflowTrigger::class,
    'run' => Packstub\Flow\Models\WorkflowRun::class,
],

Table names are prefixed so they never collide with a workflows table your application may already have. Change them before running the migration; the models read them from the config at runtime.

Swap any model for a subclass when you need extra columns, scopes or relationships — a team_id for multi-tenancy, for example. The plugin resolves them through Flow::workflowModel(), Flow::triggerModel() and Flow::runModel() everywhere, including the resource.

'models' => [
    'workflow' => App\Models\Workflow::class,   // extends Packstub\Flow\Models\Workflow
    // ...
],

The user model used by Send notification is auth.providers.users.model.

Queue

'queue' => [
    'enabled' => (bool) env('PACKSTUB_FLOW_QUEUE', false),
    'connection' => env('PACKSTUB_FLOW_QUEUE_CONNECTION'),
    'queue' => env('PACKSTUB_FLOW_QUEUE_NAME'),
    'timeout' => (int) env('PACKSTUB_FLOW_QUEUE_TIMEOUT', 300),
],

enabled pushes every triggered run onto the queue; connection and queue apply to run jobs and to the resume jobs of Wait steps (which use the queue regardless); timeout is the job timeout in seconds. See Queue & scheduling.

Execution limits

'max_steps' => 10000,
'max_nesting' => 5,
'max_records' => 1000,
'max_output_bytes' => 16384,

max_nesting caps how deep runs may start other runs; max_records caps what Find records reads, For each iterates and Date on a record starts per minute (see Runs).

Tenancy and versions

'tenancy' => [
    'relationship' => env('PACKSTUB_FLOW_TENANT_RELATIONSHIP'),
],
'versions' => [
    'keep' => 50,
],

The relationship of a triggering record that leads to its tenant (team), used when no resolver is set — see Multi-tenancy. versions.keep is how many definition versions are kept per workflow.

Audit trail

'audit' => [
    'activity_log' => true,
    'log_name' => 'packstub-flow',
],

created_by / updated_by are always recorded. With spatie/laravel-activitylog installed, activity_log also writes every creation, change, activation, deactivation and deletion to the activity log — see Audit trail.

Approvals

'approvals' => [
    'prefix' => 'flow/approvals',
    'middleware' => ['web', \Filament\Http\Middleware\Authenticate::class],
    'link_lifetime_hours' => 72,
],

The signed Approve / Reject links in notifications and emails hit {prefix}/{wait}/{outcome}; the middleware must authenticate the user so the approver can be checked. See Approvals & signals.

'max_steps' => 1000,
'max_output_bytes' => 16384,

A run fails once it has visited more than max_steps nodes. Cycles fail on their own, whatever the limit — see Runs. An action's output (an HTTP response, say) is kept on the step log up to max_output_bytes; larger outputs are stored as a truncated preview, though the nodes after it still receive the whole value.

Outgoing HTTP

'http' => [
    'timeout' => (int) env('PACKSTUB_FLOW_HTTP_TIMEOUT', 15),
    'retry_after_ms' => 500,
    'block_private_networks' => (bool) env('PACKSTUB_FLOW_HTTP_BLOCK_PRIVATE', true),
    'allowed_hosts' => [],
],

Applies to the HTTP request and Send Slack message actions.

Key
timeout Default request timeout in seconds; a node can set its own
retry_after_ms Pause between the retries a node asks for
block_private_networks Refuses URLs whose host is, or resolves to, a loopback, private, link-local or otherwise reserved address (localhost, 127.0.0.1, 10.0.0.0/8, 192.168.0.0/16, 169.254.169.254, ::1, …). The host is checked against the system resolver as well as DNS, and a host that cannot be resolved at all is refused. Only http and https are ever allowed. Turn off when workflows must reach services on your private network — and make sure only trusted users can edit workflows
allowed_hosts When not empty, requests may only go to these hosts: exact names or *.example.com wildcards. Everything else is refused, whatever block_private_networks says

Ask AI

'ai' => [
    'timeout' => (int) env('PACKSTUB_FLOW_AI_TIMEOUT', 60),
],

The default timeout of one question from the Ask AI action, in seconds; a node can set its own. Describe it on the create page uses the same timeout for its one question. Everything else about the model — provider, key, model picker, budgets and limits — is the engine's: config/packstub-agents.php and config/ai.php of Agents for Laravel, which the action needs installed (see Actions).

Decide (Jev)

'jev' => [
    'enabled' => env('PACKSTUB_FLOW_JEV'),
    'key' => env('TYPESAFE_API_KEY'),
    'model' => env('TYPESAFE_MODEL', 'jev-latest'),
    'url' => env('TYPESAFE_API_URL', 'https://api.typesafe.ai/v1/systemone'),
    'timeout' => (int) env('PACKSTUB_FLOW_JEV_TIMEOUT', 15),
    'attempts' => 3,
],
Key
key The TypeSafe API key the Decide action uses unless a node names its own (from a secret). With a key the node is offered
enabled Empty, the key decides. true offers the node without an app-wide key, for a panel where every workspace keeps its own in a secret; false hides the node
model The default model: jev-latest moves with TypeSafe's releases, a version (jev-1.13.0) stays put
url The evaluation endpoint, for a gateway or a proxy in front of TypeSafe
timeout Seconds for one decision; a node can set its own
attempts Tries in all when TypeSafe answers 429 / 529 or the connection fails, with a short backoff

The endpoint is configuration rather than something a workflow author types, so http.allowed_hosts does not apply to it.

Schedule

'register_schedule' => true,
'schedule_catch_up_minutes' => (int) env('PACKSTUB_FLOW_SCHEDULE_CATCH_UP', 0),
'schedule_on_one_server' => (bool) env('PACKSTUB_FLOW_SCHEDULE_ONE_SERVER', false),

register_schedule adds packstub-flow:cron to Laravel's scheduler every minute (withoutOverlapping()). Set to false to register the command yourself.

schedule_catch_up_minutes makes the command evaluate the minutes that were missed since its last run (a deploy, a crash, a paused scheduler), up to that many; 0 evaluates the current minute only. schedule_on_one_server adds onOneServer() to the scheduled command for setups where the scheduler runs on several servers (needs a cache driver with locks). See Queue & scheduling.

Webhooks

'webhooks' => [
    'enabled' => true,
    'prefix' => 'flow/webhooks',
    'middleware' => ['api', 'throttle:60,1'],
],
Key
enabled false removes the route entirely; webhook nodes then never fire
prefix The route is POST {prefix}/{workflow}/{token}, named packstub-flow.webhook. The Webhook trigger's settings show the resulting URL
middleware Applied to the route. Keep a throttle; add your own middleware (an IP allow-list) here
redacted_headers Request headers removed before the payload is stored on the run: authorization, cookie, x-api-key, the signature headers, …

The route is registered by the package service provider, outside any panel, so it is not affected by the panel's middleware or authentication.

Model discovery

'models_for_triggers' => [],

Models offered by the Record created / updated / deleted triggers, in addition to every model in app/Models that uses HasWorkflows and the plugin's models([...]). List models that live elsewhere here. Remember that only models with the trait fire on their own.

Nodes

'triggers' => [
    Nodes\Triggers\Manual::class,
    Nodes\Triggers\Schedule::class,
    Nodes\Triggers\Webhook::class,
    Nodes\Triggers\RecordCreated::class,
    Nodes\Triggers\RecordUpdated::class,
    Nodes\Triggers\RecordDeleted::class,
    Nodes\Triggers\UserRegistered::class,
    Nodes\Triggers\EventFired::class,
    Nodes\Triggers\WorkflowCalled::class,
],

'actions' => [
    Nodes\Actions\SendEmail::class,
    Nodes\Actions\SendNotification::class,
    Nodes\Actions\SendSlackMessage::class,
    Nodes\Actions\HttpRequest::class,
    Nodes\Actions\UpdateRecord::class,
    Nodes\Actions\Wait::class,
    Nodes\Actions\CallWorkflow::class,
    Nodes\Actions\WriteLog::class,
],

'conditions' => [
    Nodes\Conditions\RecordAttribute::class,
    Nodes\Conditions\CompareValues::class,
    Nodes\Conditions\TimeOfDay::class,
],

The nodes offered in the builder (Nodes is Packstub\Flow\Nodes). Remove a class to hide it everywhere; add your own classes here or through the plugin — see Extending.

Templates

'templates' => [],

Paths to *.json export files, directories of them, or the documents as arrays, offered under Template on the create page next to the built-in ones. See Templates.

Navigation

'navigation' => [
    'group' => null,
    'icon' => 'heroicon-o-bolt',
    'sort' => null,
],

Defaults for the Workflows resource, overridden per panel by navigationGroup(), navigationIcon() and navigationSort().

Runs retention

'prune_runs_after_days' => 30,

How old a finished run must be before packstub-flow:prune deletes it; a workflow's own Keep runs for (days) setting overrides it. With register_schedule on, the command is scheduled daily; set the value to null to keep runs forever (and not schedule the command). See Runs.

Notifications

'notifications' => [
    'recipients' => [...explode(',', env('PACKSTUB_FLOW_NOTIFY', ''))],
],

Panel users, by email, who receive a database notification when a workflow is deactivated after failing too many times in a row (PACKSTUB_FLOW_NOTIFY=ops@example.com,cto@example.com). See Runs.

Authorization

'gate' => env('PACKSTUB_FLOW_GATE'),

Workflows can call URLs, send mail and update records, so decide who may manage them. Three layers, all optional and combined:

  • a policy on the Workflow model (WorkflowPolicy, or Gate::policy(Workflow::class, ...)) — Filament applies it to the resource pages and actions as usual;
  • the plugin's authorize() callback — FlowPlugin::make()->authorize(fn () => auth()->user()->isAdmin()) hides the resource when it returns false;
  • a Gate ability named here — every panel user must pass it to see the resource.

Without any of them, every user who can access the panel can manage every workflow.

The canvas field

Packstub\Flow\Filament\Forms\Components\FlowBuilder is the form field behind the canvas. Use it in your own resource (with withoutResource()) or a custom page:

use Packstub\Flow\Filament\Forms\Components\FlowBuilder;

FlowBuilder::make('definition')
    ->hiddenLabel()
    ->minHeight(600)        // pixels, or a CSS length such as '70vh'
    ->fillViewport()        // optional: stretch to the bottom of the window
    ->columnSpanFull()

minHeight() is the height the canvas opens with (600 px by default); the user can drag its bottom-right corner to make it taller for the current page. fillViewport() stretches the canvas from where it starts on the page to the bottom of the window and follows window resizes, with minHeight() as the floor; the workflow edit page uses it with minHeight('32rem').

The field's state is the {nodes, edges} structure described in Building workflows; the model attribute should be cast to array. The field validates the definition before it is saved — a trigger must exist, every other node must be connected, required settings must be filled — when the form has an is_active field that is on. ->withoutValidation() skips those checks.

Translations and views

php artisan vendor:publish --tag="packstub-flow-translations"
php artisan vendor:publish --tag="packstub-flow-views"

Strings — including every node name, description, field label and step message — live under lang/vendor/packstub-flow/{locale}/flow.php; views under resources/views/vendor/packstub-flow/ (forms/components/flow-builder, forms/placeholders, livewire/manage-node, mail/workflow, runs/detail).

Next: Testing.