Triggers
A trigger starts a run. Each trigger class decides, per workflow, whether an incoming payload should start that workflow (matches()), and defines what the payload contains — which is what placeholders and conditions read.
How dispatching works
use Packstub\Flow\Facades\Flow;
use Packstub\Flow\Nodes\Triggers\RecordUpdated;
$runs = Flow::dispatch(RecordUpdated::class, ['model' => $order]);
Flow::dispatch() looks up every active workflow that has a trigger node of that class (from the flow_workflow_triggers table, kept in sync when a workflow is saved), asks the trigger whether the node's settings match the payload, and starts a run from that node for each match. It returns the runs that finished synchronously — an empty array when runs were pushed onto the queue or nothing matched. An unregistered trigger class matches nothing.
A workflow may have several trigger nodes; the run starts from the one that fired and follows only the edges leaving it. When a run is started without a specific trigger — Run now, packstub-flow:run, Flow::run($workflow) — it starts from the first trigger node in the definition.
Every trigger's payload is also available to your own code through the WorkflowStarted / WorkflowCompleted / WorkflowFailed events (see Runs).
Manual
Starts from the Run now button (table row and edit page header), from a Run workflow action on any resource, from the console with packstub-flow:run, or from code with Flow::run().
| Setting | |
|---|---|
| Record type | Optional. When set, the workflow is offered by the Run workflow resource action only for records of that type, and its nodes can rely on {{ model.* }} |
| Payload | From |
|---|---|
manual |
true when started with Run now or a resource action |
model |
The record, when started from a resource action (alias record) |
| anything else | Whatever you pass to Flow::run() or --payload |
Run now passes ['manual' => true] and nothing else, so nodes that need a record (record conditions, Update record) fail on such a run. Use it to try schedules, webhooks, notifications and logging.
Flow::run($workflow, ['answer' => 42]);
From a resource action
RunWorkflowAction turns any Filament resource into an entry point: a Run workflow button on a row (or a bulk action on a selection) that lists the active workflows whose Manual trigger accepts the record type, and starts the chosen one with the record as {{ model }}.
use Packstub\Flow\Filament\Actions\RunWorkflowAction;
use Packstub\Flow\Filament\Actions\RunWorkflowBulkAction;
public static function table(Table $table): Table
{
return $table
->recordActions([RunWorkflowAction::make()])
->toolbarActions([RunWorkflowBulkAction::make()]);
}
Both actions are hidden when no workflow qualifies. ->workflows([$id, ...]) (or a closure) restricts the list; ->payload(fn (Model $record) => ['source' => 'orders-table']) adds keys to the payload. The result is reported as a notification: how many records ran, were queued, or failed (with the errors).
Schedule
Runs on a cron expression. packstub-flow:cron — registered with Laravel's scheduler every minute, see Queue & scheduling — dispatches this trigger with the current time, and every workflow whose expression is due at that minute starts.
| Setting | |
|---|---|
| Cron expression | Five fields: minute, hour, day of month, month, day of week. Validated when you apply the settings. 0 9 * * 1-5 is weekdays at 09:00 |
| Timezone | The timezone the expression is evaluated in; empty means the application timezone (app.timezone) |
A node with an invalid expression never matches.
| Payload | |
|---|---|
now |
The Carbon instance the command ran with; {{ now }} renders it as an ISO 8601 date |
Webhook
Starts when a POST request hits the workflow's webhook URL. Each webhook node has its own secret token; the URL is:
POST {app url}/flow/webhooks/{workflow id}/{token}
| Setting | |
|---|---|
| Secret token | Generated for you (40 random characters); 16–120 letters, digits, dashes and underscores. The settings panel shows the full URL prefix |
| Signing secret | Optional. When set, every request must carry an HMAC-SHA256 signature (hex, with or without a sha256= prefix) of the raw body, computed with this secret |
| Signature header | The header that carries the signature; X-Signature by default |
The endpoint:
- answers
202 Acceptedwith{"accepted": true, "run": "<run id>", "status": "success"}— with queued runs (see Queue & scheduling)runisnullandstatusis"queued"; - answers
404when the workflow does not exist, is inactive, or the token does not match any webhook node of that workflow (tokens are compared in constant time); - answers
401when the node has a signing secret and the signature is missing or wrong; - reads a JSON body when the request is JSON, and form fields otherwise.
curl -X POST https://example.com/flow/webhooks/9d2f4a1e-.../your-secret-token \
-H 'Content-Type: application/json' \
-d '{"order": {"id": 42, "status": "shipped"}}'
| Payload | |
|---|---|
webhook |
The request body: {{ webhook.order.status }} |
headers |
Request headers, first value each, lower-cased names: {{ headers.x-request-id }}. Credential headers (Authorization, Cookie, X-Api-Key, the signature header, …) are dropped before the payload is stored — the list is webhooks.redacted_headers in the config |
webhook_token |
The token from the URL (used for matching) |
The route is named packstub-flow.webhook, runs under the api and throttle:60,1 middleware by default, and can be disabled or moved to another prefix in the config — see Configuration.
Note
Treat the URL as a secret and keep the throttle middleware. When the sender can sign requests (most services can), set a signing secret so a leaked URL is not enough to start the workflow.
Record created / updated / deleted
Fire from the Eloquent created, updated and deleted events of models that use the HasWorkflows trait:
use Packstub\Flow\Concerns\HasWorkflows;
class Order extends Model
{
use HasWorkflows;
}
| Setting | |
|---|---|
| Record type | The model class. The list contains every non-abstract model in app/Models that uses HasWorkflows, plus the classes in models_for_triggers (config) and FlowPlugin::make()->models([...]) |
| Run once per record | Skips a record the workflow has already run for (any status). Welcome series, surveys and reminders should not fire twice |
| Not more than once per record every N days | A dedup window: skips a record the workflow ran for in the last N days (abandoned-cart nudges, payment reminders). Shown when Run once per record is off |
| Only when these attributes change | Record updated only: attribute names; the trigger fires only when at least one of them is in changes. Empty fires on every update |
| Changed from / Changed to | Record updated only, with attributes to watch: the old and / or new value one of them must have. status from pending to paid fires once, when that exact change happens (case-insensitive) |
A node matches when the payload's model is an instance of the chosen class (subclasses included). "Status becomes paid" is Record updated watching status with Changed to paid — or, for a condition further down the graph, a Record attribute condition with the changed to operator; {{ original.status }} still holds the previous value. Runs remember the record they started for (subject_type / subject_id), which the Runs tab shows and searches.
| Payload | Provided by |
|---|---|
model |
The record ({{ model.status }}, {{ model.customer.name }}); record is an alias |
original |
Updated only: the attributes before the update ({{ original.status }}) |
changes |
Updated only: just the attributes that changed ({{ changes.status }}) |
Some details worth knowing:
- Mass updates (
Order::query()->update()),saveQuietly()/updateQuietly()and soft-delete restores fire nocreated/updated/deletedevent and therefore no trigger; a soft delete fires Record deleted. - A trigger fired inside a database transaction starts the run right away in sync mode; with the queue enabled the job is dispatched after the transaction commits.
- Saves made with
saveQuietly()/updateQuietly()fire no Eloquent events and therefore no trigger. The Update record action saves quietly by default for exactly this reason: a workflow that updates the record that started it would otherwise start itself again. - A model listed in the config or the plugin without the trait appears in the select but never fires on its own — dispatch the trigger yourself, or add the trait.
- A Record deleted run that continues through the queue (after a Wait) gets the deleted record rebuilt from the attributes it had, so placeholders keep working.
You can dispatch the same triggers from code, e.g. from an observer, a job or a console command:
Flow::dispatch(RecordCreated::class, ['model' => $order]);
Flow::dispatch(RecordUpdated::class, ['model' => $order, 'original' => $before, 'changes' => $order->getChanges()]);
Flow::dispatch(RecordDeleted::class, ['model' => $order]);
And you can switch every trigger off for a block of code — imports, seeders, backfills — with Flow::suppress(); explicit Flow::run() calls still work inside it:
Flow::suppress(fn () => Order::factory()->count(1000)->create());
User registered
Fires on Laravel's Illuminate\Auth\Events\Registered event, which the framework's registration flows (Fortify, Breeze, Jetstream, your own controller calling event(new Registered($user))) dispatch. No settings.
| Payload | |
|---|---|
model |
The registered user ({{ model.email }}) |
user |
The same user, so {{ user.name }} reads naturally too |
Event
Fires when a Laravel event of the chosen class is dispatched. The plugin registers one wildcard listener; the set of watched event classes is loaded once per process and cached for an hour, so the cost per unrelated event is a hash lookup.
| Setting | |
|---|---|
| Event class | Fully qualified class name, e.g. App\Events\OrderShipped. Must exist; a leading backslash is fine |
| Payload | |
|---|---|
event |
The event object. Its public properties are placeholders: {{ event.carrier }}, {{ event.order.total }} |
Details:
- The listener keys on the class name the event was dispatched under, so configure the concrete class you dispatch. String events (
event('order.shipped', [...])) and events without an object are ignored. - Events dispatched while a workflow run is executing (for example by an action) do not start further Event-triggered workflows; that guard keeps a workflow from feeding itself.
- The cached list is refreshed whenever a workflow is saved or deleted. Long-running processes (queue workers, Octane) keep the list they loaded until they restart or the hour passes.
// App\Events\OrderShipped
class OrderShipped
{
public function __construct(public Order $order, public string $carrier) {}
}
Called by another workflow
Starts when another workflow reaches a Call workflow action pointing at this workflow. The calling workflow's payload is passed along, plus flow_depth, the number of nested calls so far. No settings.
A workflow can have both this trigger and others; the caller always enters through this node. See the Call workflow action for the depth limit and failure handling.
Date on a record
"Three days before the due date", "one hour after the appointment starts", "when the trial ends": fires for every record whose date column, shifted by the offset, falls in the current minute. Evaluated by packstub-flow:cron — a running scheduler is all it needs, and catch-up covers minutes the scheduler missed.
| Setting | |
|---|---|
| Record type | The model class |
| Date attribute | A datetime column: due_at, trial_ends_at, starts_at |
| Offset / unit / When | 3 days before the date, 1 hour after it, or 0 at it |
| Run once per record | On by default: a record starts this workflow once, whatever happens to the cron schedule |
| Payload | |
|---|---|
model |
The record |
date |
The value of the date column |
now |
The minute being evaluated |
Records are read in batches of at most max_records (config, 1000) per minute. Combine with Find records + For each when one run should handle a whole batch instead.
State transitioned
Available when spatie/laravel-model-states is installed. Fires on the package's StateChanged event, i.e. every $order->status->transitionTo(Paid::class).
| Setting | |
|---|---|
| Record type | The model class |
| State field | Optional: only transitions of this state attribute (status). Empty matches any state field of the model |
| From state / To state | Optional: the state's name (as stored in the column, paid), its class (App\States\Paid) or short class name (Paid), case-insensitive. Empty matches any |
| Payload | |
|---|---|
model |
The record |
field |
The state attribute |
from, to |
The state names ({{ from }} → {{ to }}) |
from_state, to_state |
The state objects |
The Transition state action moves a record to another state from a workflow.
Status changed
Available when spatie/laravel-model-status is installed. Fires on the package's StatusUpdated event, i.e. every $ticket->setStatus('approved', 'Looks fine').
| Setting | |
|---|---|
| Record type | The model class |
| From status / To status | Optional status names, case-insensitive |
| Payload | |
|---|---|
model |
The record |
from, to |
The previous and new status names |
reason |
The reason given with the new status |
Writing your own trigger
See Extending.
Next: Actions.