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 |
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.
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 |
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.
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
Workflowmodel (WorkflowPolicy, orGate::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 returnsfalse; - 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'
->columnSpanFull()
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.