packstub / filament-flow
Visual workflow automations for Filament panels: triggers, conditions and actions drawn on a canvas, run by your queue.
Fund package maintenance!
Requires
- php: ^8.3
- dragonmantank/cron-expression: ^3.3
- filament/filament: ^4.0 || ^5.0
- illuminate/contracts: ^12.0 || ^13.0
- spatie/laravel-package-tools: ^1.92
Requires (Dev)
- larastan/larastan: ^3.11
- laravel/pint: ^1.27
- orchestra/testbench: ^10.0 || ^11.0
- pestphp/pest: ^4.0 || ^5.0
- pestphp/pest-plugin-laravel: ^4.0 || ^5.0
- pestphp/pest-plugin-livewire: ^4.0 || ^5.0
- spatie/laravel-model-states: ^2.11
- spatie/laravel-model-status: ^1.20
- spatie/laravel-tags: ^4.12
Suggests
- packstub/agents: Adds the Ask AI action: a model answers a question built from the run with fields the next nodes branch on (PHP 8.4).
Provides
None
Conflicts
None
Replaces
None
README
Visual workflow automations for your Filament panel: draw triggers, conditions and actions on a canvas, and let the runner — or your queue — do the rest.
Features
- Visual builder: a drag-and-drop canvas in a Filament resource, each node's settings in a slide-over.
- Triggers: record changes, dates on a record, schedules, webhooks, any Laravel event, or a button on any resource.
- Conditions: branch on a record attribute, any two values or the time of day, with twenty operators.
- Actions: emails, notifications, Slack, Teams, SMS, HTTP calls, record updates, loops, waits and AI, with retries.
- Placeholders and secrets:
{{ model.name }}in any text a node sends, API tokens encrypted and masked in run logs. - Approvals and signals: pause a run until a person decides or your code calls
Flow::signal(). - Templates, import and export: start from a template or a sentence, move workflows between panels as JSON.
- Runs and versions: inline or queued, a step-by-step log of every run, dry-run tests, every change kept to restore.
- Multi-tenant: each team keeps its own workflows, secrets, runs and approvals, with a plan limit hook.
- Extensible: your own triggers, actions and conditions, registered on the plugin or with
Flow::register(). - Dark mode and translations: the canvas follows Filament's theme, and every string lives in a language file.
Compatibility
| Plugin | Filament | Laravel | PHP |
|---|---|---|---|
| 1.x | 4.x, 5.x | 12.x, 13.x | 8.3+ |
Installation
composer require packstub/filament-flow php artisan packstub-flow:install
Add the trait to the models you want to automate, and the plugin to your panel:
use Packstub\Flow\Concerns\HasWorkflows; class Order extends Model { use HasWorkflows; }
use Packstub\Flow\FlowPlugin; $panel->plugin( FlowPlugin::make() ->navigationGroup('Automation'), );
A Workflows resource appears in the panel navigation.
Full walkthrough: Installation.
The builder
A workflow is a graph: one or more trigger nodes, followed by actions and conditions connected by edges. Draw it on the canvas — add nodes with the Add node button, the right-click menu or the plus on an unconnected output handle, drag handles together to connect them, and use a condition's True and False outputs to branch.
Every node has a label, a description and its own settings, edited in a slide-over you open with the gear icon or a double-click. The graph is saved with the workflow as plain JSON (nodes and edges), so it is easy to seed, export and test.
use Packstub\Flow\Filament\Forms\Components\FlowBuilder; // The canvas is a form field — use it in your own resource if you like FlowBuilder::make('definition')->minHeight('70vh')
Not sure where to start? New workflow opens one page with every way in: a Blank canvas; a Template — a welcome series, a high-value order alert, dunning reminders, an SLA escalation and an approval flow, each previewed as a diagram with what it leaves for you to fill in, ready to adapt; with packstub/agents installed, Describe it turns a sentence ("when an order over $500 comes in, post to Slack and flag it for review") into a draft built from your own triggers, conditions and actions; or Import a workflow exported from another panel as a JSON file (Export is on the edit page). Everything but the blank canvas opens inactive with the nodes still to fill in marked.
Read more: Building workflows, Templates, import and export.
Triggers
A trigger starts a run and decides what the payload contains. Pick one from the sidebar and configure it in its settings — the model for a record trigger, a cron expression for a schedule, the event class for an event trigger.
| Trigger | Fires when |
|---|---|
| Manual | You press Run now, use a Run workflow action on a resource, run packstub-flow:run, or call Flow::run() |
| Schedule | A cron expression is due (checked every minute by packstub-flow:cron, with optional catch-up of missed minutes) |
| Webhook | A POST hits /flow/webhooks/{workflow}/{token} |
| Record created / updated / deleted | A model using HasWorkflows is created, updated or deleted — optionally only when given attributes change from one value to another, once per record, or not more than once every N days |
| Date on a record | A date column, shifted by an offset, is reached: "3 days before due_at", "1 hour after starts_at" |
| State transitioned / Status changed | A spatie/laravel-model-states state or spatie/laravel-model-status status changes (offered when the package is installed) |
| User registered | Laravel's Illuminate\Auth\Events\Registered event is dispatched |
| Event | Any Laravel event of the configured class is dispatched |
| Called by another workflow | Another workflow runs a Call workflow action pointing at this one |
A workflow can have several triggers; the run starts from the one that fired. Fire your own from code:
use Packstub\Flow\Facades\Flow; use Packstub\Flow\Nodes\Triggers\RecordUpdated; Flow::dispatch(RecordUpdated::class, ['model' => $order]);
Any Filament resource can start a workflow for a record with RunWorkflowAction (and RunWorkflowBulkAction for a selection):
->recordActions([\Packstub\Flow\Filament\Actions\RunWorkflowAction::make()])
Read more: Triggers.
Conditions
A condition has a True and a False output; the run follows the branch that matches. Record attribute checks a column, accessor or dotted relationship path (customer.country) on the record that started the run, and knows whether it changed, changed from or changed to a value; Compare values compares any two values, placeholders allowed on both sides; Multiple conditions joins several rules with AND / OR; Time of day is true inside a daily window in a timezone of your choice.
Operators: equals, does not equal, greater / less than (or equal), contains, does not contain, starts with, ends with, is one of, is empty, is not empty, is true, is false, is null, is not null, matches regular expression, is a date before / after.
Read more: Conditions.
Actions
Actions do the work. Each one's settings are a small Filament form; text fields accept placeholders.
| Action | What it does |
|---|---|
| Send email | A plain Markdown email to one or more addresses, with an optional button ({{ model.url }} opens the record in the panel) |
| Send notification | A Filament database notification to panel users, picked by email, with an optional action button |
| Send Slack / Discord / Teams / Telegram message | Posts to a Slack or Discord webhook, a Teams Workflows webhook, or a Telegram bot chat |
| Send SMS (Twilio) | An SMS or WhatsApp message through the Twilio Messages API |
| HTTP request | Calls any URL with headers and a JSON body; can fail the run on a 4xx / 5xx response |
| Send to Zapier, Make or n8n | Posts the run (or a JSON body of your own) to a Catch Hook, custom webhook or Webhook node, signed with HMAC-SHA256, so the integration side happens on the platform you already use |
| Ask AI | Asks a language model a question built from the run and gets named fields back ({{ last.urgency }}) to branch on; runs on Agents for Laravel (packstub/agents, free, PHP 8.4) for the provider, the workspace's key and the operator's budgets, and is offered only when it is installed |
| Update record | Sets attributes on the record that started the run, quietly by default |
| Create record | Creates a record, on its own or through a relationship of the current one |
| Assign owner | Sets a user on the record: a fixed one, or round robin over a list |
| Add tag | Attaches, detaches or syncs spatie/laravel-tags tags |
| Transition state | Moves a spatie/laravel-model-states state to another one |
| Find records / For each | Queries records and runs a branch once per item ({{ item.* }}) |
| Ask for approval | Pauses until an approver decides — Approved / Rejected / Timed out outputs, an Approvals page, notification and email links |
| Wait for signal | Pauses until your code calls Flow::signal('payment.42', [...]) |
| Wait | Pauses the run for seconds, minutes, hours or days; the rest continues through the queue |
| Call workflow | Runs another workflow with the current payload |
| Write to log | Writes a line to the application log at the chosen level |
Every action has an Error handling section: retry, continue, or follow an Error branch with {{ error.message }}. A workflow can also name an on-failure workflow.
Find records and For each give a workflow a loop: query records, run a branch once per item with {{ item.* }}, then continue from Done.
Read more: Actions.
Placeholders
Any text a node sends or compares can reference the run's payload with {{ path }}. Paths are resolved with data_get(), so relationships and nested arrays just work:
Order {{ model.reference }} for {{ model.customer.name }} is now {{ model.status }}
Previously {{ original.status }} — changed: {{ changes.status }}
Webhook said {{ webhook.order.total }}; the event carrier is {{ event.carrier }}
{{ record.x }} is an alias of {{ model.x }}; {{ model.url }} is the record's page in the panel. Each node's settings panel lists the placeholders its trigger provides.
Read more: Placeholders.
Secrets
Tokens and webhook URLs live on the Secrets page, encrypted with your app key and never shown again. Actions reference them as {{ secrets.slack_webhook }}; conditions and triggers cannot read them, and every resolved value is masked in the run log. In a panel with tenancy each tenant keeps its own secrets.
Read more: Secrets.
Approvals & signals
Two actions pause a run until something outside the workflow happens. Ask for approval waits for a person: approvers are notified in the panel and by email, and the run continues along Approved, Rejected or Timed out. Wait for signal waits for your code.
Pending requests are listed on the Approvals page, with a badge counting the ones waiting for the signed-in user.
use Packstub\Flow\Facades\Flow; // Continue every run waiting on this key along its "Received" output Flow::signal('payment.42', ['paid_at' => now()]);
Read more: Approvals & signals.
Runs
Every run is recorded: status (Running, Waiting, Succeeded, Failed), the trigger that started it, a summary of the payload, when it started and how long it took, a step-by-step log, and the error message if something threw. The Runs tab under a workflow lists them and refreshes on its own.
Open a run to see each step in order.
Test on the edit page performs a dry run — conditions evaluated, side effects simulated and logged as "would run". Run now (in the table and on the edit page) starts an active workflow from its first trigger, and packstub-flow:run does the same from the console. The Runs page lists every run across workflows with stats and a jump to the failing node on the canvas. packstub-flow:prune deletes finished runs older than the configured retention (or the workflow's own). A workflow can switch itself off after N consecutive failures, notify your admins, and hand failures to an on-failure workflow.
Every save is kept as a version with who saved it and a summary of what changed; the Versions tab compares and restores them, and a run pins the version it ran. Outgoing requests cannot reach private networks unless you allow it, record updates respect mass-assignment rules, webhook credential headers are dropped before a run is stored, and hidden model attributes never reach a template.
php artisan packstub-flow:run "Welcome sequence" --payload='{"answer": 42}' php artisan packstub-flow:prune --days=30
Read more: Runs.
Queue & scheduling
By default a workflow runs inside the request or model event that triggered it. Set PACKSTUB_FLOW_QUEUE=true to push every run onto the queue instead — optionally on its own connection and queue name. Wait steps always continue through the queue, so run a worker whenever you use them.
PACKSTUB_FLOW_QUEUE=true PACKSTUB_FLOW_QUEUE_NAME=flows
Schedule triggers are evaluated by packstub-flow:cron, which the plugin registers with Laravel's scheduler every minute. A running schedule:work (or a cron entry for schedule:run) is all you need.
Read more: Queue & scheduling.
Webhooks
A Webhook trigger gives the workflow a secret token. Send a POST to /flow/webhooks/{workflow id}/{token} and the JSON body is available to every node as {{ webhook.* }}. The endpoint answers 202 Accepted with the run id and status, and 404 for an unknown workflow, an inactive one or a wrong token.
curl -X POST https://example.com/flow/webhooks/9d2f.../your-secret-token \ -H 'Content-Type: application/json' \ -d '{"order": {"id": 42, "status": "shipped"}}'
The prefix, middleware (api and throttle:60,1 by default) and an on/off switch live in the config.
Read more: Webhooks.
Multi-tenancy
In a panel with ->tenant(Team::class) the Workflows and Secrets resources, the Runs and Approvals pages are scoped to the current tenant, and new workflows belong to it. When a trigger fires, the tenant of the record decides which workflows run: the tenant's own plus the global ones. Tell the runtime where a payload's tenant comes from with a resolver or a relationship name, and cap workflows per tenant with maxWorkflows().
FlowPlugin::make() ->resolveTenantUsing(fn (array $payload) => $payload['model']?->team) ->maxWorkflows(fn (?Team $team): ?int => $team?->plan->limit('workflows'))
Read more: Multi-tenancy.
Extending
A node is a class with a name, an icon, a Filament form schema and one method: matches() for a trigger, evaluate() for a condition, handle() for an action.
use Filament\Forms\Components\TextInput; 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 getIcon(): ?string { return 'heroicon-o-user-group'; } public function getFormSchema(): array { return [TextInput::make('team')->required()]; } public function handle(array $config, array $payload): void { $payload['model']->update(['team' => $this->interpolate($config['team'], $payload)]); } }
FlowPlugin::make()->actions([AssignToTeam::class])
Read more: Extending.
Configuration
use Packstub\Flow\FlowPlugin; FlowPlugin::make() ->triggers([OrderPaid::class]) // add your own nodes ->actions([AssignToTeam::class]) ->conditions([IsVip::class]) ->without([SendSlackMessage::class]) // hide built-in nodes ->models([Order::class, Invoice::class]) // offered by the record triggers ->navigationGroup('Automation') ->navigationIcon('heroicon-o-bolt') ->navigationSort(10) ->resource(App\Filament\Resources\WorkflowResource::class) // or ->withoutResource()
Read more: Configuration — the config file, tables and models, queue, webhooks, execution limits and retention.
Documentation
- Installation
- Building workflows
- Triggers
- Actions
- Conditions
- Placeholders
- Secrets
- Approvals & signals
- Templates, import and export
- Runs
- Multi-tenancy
- Queue & scheduling
- Extending
- Configuration
- Testing
The same pages live in the docs/ directory of this repository.
Testing
composer test # Pest, sqlite in memory (DB_CONNECTION=mysql for a MySQL server) composer analyse # PHPStan bun run check # svelte-check on the canvas sources bun run test # vitest, the canvas helpers bun run test:e2e # Playwright smoke test against `vendor/bin/testbench serve`
Changelog
See the changelog.
Security vulnerabilities
Please e-mail support@packstub.dev rather than opening a public issue.
Credits
License
MIT. See the license file.













