Actions
An action does the work of a workflow. Its settings are a Filament form in the node's slide-over; every text field accepts placeholders, which are filled in from the run's payload when the action runs. An action that throws marks the run as Failed with the exception message and stops that branch — unless its Error handling section says to retry, continue, or follow an Error branch (see Runs).

Send email
Sends a plain Markdown email through your default mailer, using the Packstub\Flow\Mail\WorkflowMail mailable.
| Setting | |
|---|---|
| To | One or more addresses separated by commas; placeholders allowed. Defaults to {{ model.email }} |
| Subject | Placeholders allowed |
| Message | Plain text; line breaks are kept. Placeholders allowed |
| Button label / Button URL | Optional: a button under the message. {{ model.url }} is the record's page in the panel (see Placeholders). The button appears only when both are filled in |
When the recipient list is empty after placeholders are filled in (for example {{ model.email }} on a run without a record), the action does nothing and the run continues. The mail is sent immediately from the run; put the workflow on the queue to keep it out of the request (see Queue & scheduling).
Send notification
Sends a Filament database notification — the bell in the topbar — to panel users.
| Setting | |
|---|---|
| Title | Placeholders allowed |
| Body | Optional; placeholders allowed |
| Style | Info (default), Success, Warning or Danger |
| Recipients | Email addresses separated by commas; placeholders allowed, e.g. admin@example.com, {{ model.owner.email }} |
| Button label / Button URL | Optional: an action button on the notification, marked as read when clicked. The URL defaults to {{ model.url }}, the record's page in the panel; the button appears only when both resolve to something |
Recipients are looked up by email on the model configured in auth.providers.users.model; addresses that match no user are skipped, and nothing is sent when none match. Your panel needs ->databaseNotifications() and the notifications table for users to see them.
Send Slack message
Posts a message to a Slack incoming webhook.
| Setting | |
|---|---|
| Webhook URL | The https://hooks.slack.com/services/... URL, or {{ secrets.slack_webhook }} from the Secrets store; other placeholders allowed too |
| Message | The text to post; placeholders allowed |
The action sends {"text": "<message>"} and fails the run if Slack answers with an error status. An empty URL skips the action.
Send Discord message
Posts to a Discord channel webhook.
| Setting | |
|---|---|
| Webhook URL | The https://discord.com/api/webhooks/... URL or {{ secrets.discord_webhook }} |
| Message | Up to 2000 characters; placeholders allowed |
| Sender name | Optional; overrides the webhook's display name |
Send Teams message
Posts to a Microsoft Teams channel.
| Setting | |
|---|---|
| Webhook URL | The URL of a Teams Workflows webhook ("Post to a channel when a webhook request is received") or of a classic Incoming Webhook connector; {{ secrets.teams_webhook }} works |
| Title | Optional; placeholders allowed |
| Message | Placeholders allowed |
| Webhook type | Workflows webhook (Adaptive Card) — the default, what new Teams webhooks expect — or Classic incoming webhook connector (text) |
Send Telegram message
Sends a message through a Telegram bot with the Bot API's sendMessage.
| Setting | |
|---|---|
| Bot token | From @BotFather. Keep it in the Secrets store: {{ secrets.telegram_bot_token }} |
| Chat | A chat or channel id (-1001234567890) or @channelusername for public channels; placeholders allowed |
| Message | Up to 4096 characters; placeholders allowed |
| Formatting | Plain text, MarkdownV2 or HTML |
Exposes {{ last.message_id }}. Errors are reported with Telegram's description, never with the URL (which carries the token).
Send SMS (Twilio)
Sends an SMS — or a WhatsApp message — through the Twilio Messages API.
| Setting | |
|---|---|
| Account SID / Auth token | Your Twilio credentials; keep them in the Secrets store |
| From | A Twilio number (+15551234567) or a Messaging Service SID (MG...) |
| To | The recipient; placeholders allowed ({{ model.phone }}) |
| Message | Up to 1600 characters; placeholders allowed |
| Send as WhatsApp message | Prefixes both numbers with whatsapp: |
Exposes {{ last.sid }} and {{ last.status }}. Other providers and any REST API — Vonage, Brevo, Mailchimp, Klaviyo — are one HTTP request action away, with the credentials in secrets: Authorization: Bearer {{ secrets.brevo_key }}.
HTTP request
Calls any URL.
| Setting | |
|---|---|
| Method | GET, POST (default), PUT, PATCH or DELETE |
| URL | Placeholders allowed: https://api.example.com/hooks/{{ model.id }} |
| Headers | Key / value pairs; placeholders allowed in values |
| JSON body | Sent as JSON for every method except GET. Must be valid JSON once placeholders are masked, which the form checks; placeholders allowed in values and as bare values |
| Timeout | Seconds before the request is abandoned; empty uses http.timeout from the config (15 s) |
| Retries on connection errors | Extra attempts (0–5) when the request cannot be sent or times out, half a second apart |
| Fail the run on a 4xx / 5xx response | On by default. Turn off to continue whatever the response |
Placeholders inside a quoted JSON string stay strings and are escaped, so a value containing quotes cannot add keys to the body ("note": "{{ model.note }}" is always one string). A bare placeholder ("total": {{ model.total }}, "customer": {{ model }}) becomes the raw value — number, boolean, array, or a model's attributes — so types survive. Placeholder values in the URL are URL-encoded. A body that is not valid JSON at run time fails the run.
The response is available to the nodes after the request:
| Placeholder | |
|---|---|
{{ last.status }} |
The HTTP status code |
{{ last.ok }} |
1 for 2xx, 0 otherwise |
{{ last.body.id }} |
The decoded JSON body (or the raw text when it is not JSON) |
{{ last.headers.content-type }} |
Response headers |
last is replaced by the output of the next action that produces one; {{ outputs.<node id>.body.id }} reaches a specific request. See Placeholders.
Destinations are checked before the request is sent: private and reserved addresses (localhost, 10.x, 192.168.x, 169.254.x, …) are refused, and http.allowed_hosts in the config can restrict requests to a list of hosts. See Configuration.
Update record
Sets attributes on the record that started the run — the model in the payload of a record trigger, the user of User registered, or whatever you passed as model to Flow::run().
| Setting | |
|---|---|
| Attributes | Attribute / value pairs; placeholders allowed in values. A value that is exactly one placeholder keeps its type ({{ webhook.total }} writes a number, {{ missing }} writes null) |
| Save without firing events | On by default |
| Bypass mass-assignment protection | Off by default |
Attributes go through the model's $fillable / $guarded rules: writing a guarded attribute fails the run with a message naming it. Turn on Bypass mass-assignment protection to use forceFill() instead — only when everyone who can edit workflows may write any column. The action exposes the saved changes as {{ last.changes.status }}. With Save without firing events on, the record is saved with saveQuietly(): no Eloquent events, no observers, and no Record updated trigger — which keeps a workflow that reacts to updates from starting itself again. Turn it off when you want observers and other workflows to see the change.
The action fails the run when the payload has no record, and does nothing when no attributes are configured.
Create record
Creates a record — on its own, or through a relationship of the record that started the run ("add a note to this order").
| Setting | |
|---|---|
| Record type | The model class |
| Through relationship | Optional: a hasMany / morphMany relationship of {{ model }} (notes); the new record is created on it, so the foreign key is set for you |
| Attributes | Attribute / value pairs; placeholders allowed, a bare placeholder keeps its type |
| Create without firing events | On by default (Model::withoutEvents()), so a Record created trigger does not start another workflow |
| Bypass mass-assignment protection | Off by default; otherwise attributes must be fillable |
Continue with the new record as {{ model }} |
The nodes after this one see the new record instead of the original |
Exposes {{ last.id }}, {{ last.type }} and {{ last.record.<attribute> }}.
Assign owner
Sets a user on the record that started the run.
| Setting | |
|---|---|
| Attribute | user_id by default |
| Assign | A specific user — an email or id, placeholders allowed — or The next user in turn: a list of emails, assigned round robin (the turn counter lives in the cache) |
| Save without firing events | On by default |
Exposes {{ last.owner_id }}, {{ last.owner_email }} and {{ last.owner_name }}. Fails the run when no user matches.
Add tag
Available when spatie/laravel-tags is installed and the model uses HasTags.
| Setting | |
|---|---|
| Tags | Comma separated; placeholders allowed (vip, {{ model.country }}) |
| Tag type | Optional |
| Mode | Attach (default), Detach or Sync (replace) |
Find records
Queries records for a For each loop: "orders unpaid for three days", "users without a login this month".
| Setting | |
|---|---|
| Record type | The model class |
| Conditions | Attribute, operator, value — all must match. Operators: equals, does not equal, greater / less than (or equal), contains, does not contain, is one of, is not one of, is null, is not null, is a date before / after. Values accept placeholders (a bare placeholder keeps its type); the date operators accept relative dates: -3 days, now, next monday |
| Order by / Direction | Optional |
| Limit | 100 by default, never more than max_records (config, 1000) |
The action changes nothing, so a test run executes it for real. Exposes {{ last.count }}, {{ last.ids }} and {{ last.records }} — the step log keeps the count and the first ids; the branch gets the records.
For each
Runs the nodes on its Each item output once per item of a list, then continues along Done.

| Setting | |
|---|---|
| Items | A placeholder for a list — {{ last.records }} after Find records, {{ webhook.items }}, or comma-separated text |
| Item name | The current item is {{ item }} (or the name you choose: {{ order.reference }}) |
| Maximum iterations | The run fails when the list is longer (100 by default, never more than max_records) |
Inside the loop body, {{ loop.index }} (from 0), {{ loop.number }} (from 1), {{ loop.count }}, {{ loop.first }} and {{ loop.last }} are available too; a Wait inside the body pauses that iteration only. Outputs made inside the body do not leak to the Done branch, which sees {{ last.count }} — the number of items. Loops nest.
Ask for approval
Pauses the run until an approver decides, from the notification, an email link or the Approvals page. Outputs Approved, Rejected and Timed out. See Approvals & signals.
Wait for signal
Pauses the run until your code calls Flow::signal('<key>', [...]). Outputs Received and Timed out. See Approvals & signals.
Transition state
Available when spatie/laravel-model-states is installed. Moves the record's state through the package, so the transition class configured on the model runs and the StateChanged event fires (which can start a State transitioned workflow).
| Setting | |
|---|---|
| State field | The state attribute, status by default |
| To state | The state name (as stored) or class; placeholders allowed |
Exposes {{ last.from }} and {{ last.to }}. A transition the model does not allow fails the run with a message naming both states.
Wait
Pauses the run and continues the nodes after it later, through the queue.
| Setting | |
|---|---|
| Wait | For a duration (default) or Until a date from the payload |
| Duration / Unit | The length of the wait, or the offset from the date: Seconds, Minutes (default), Hours or Days |
| Date | Until mode only: a placeholder or a date, e.g. {{ model.starts_at }} or {{ model.due_at }} |
| Offset | Until mode only: continue that duration before or after the date |
"1 day before {{ model.starts_at }}" sends an appointment reminder; "3 days after {{ model.due_at }}" is a dunning step. A date already in the past (after the offset) continues immediately, and a date that cannot be read from the payload does too.
When the runner reaches a Wait, it records the nodes connected to the Wait's output, marks the run Waiting, and dispatches a delayed job that resumes them once the time has passed. A Wait with nothing connected after it finishes the run immediately. Everything about how this interacts with your queue — the graph snapshot, several Waits in one run, the sync driver — is in Queue & scheduling.
Call workflow
Runs another workflow with the current payload.
| Setting | |
|---|---|
| Workflow | The workflow to call. It must contain a Called by another workflow trigger; the call enters through that node |
The called workflow receives the caller's payload plus flow_depth, which grows by one per nested call. When workflows call each other more than 10 levels deep, the run fails with a clear message instead of looping.
When runs execute synchronously (the default), the called workflow runs to completion inside the calling action; if it fails, the caller fails too with the same error. With queued runs, the called workflow is pushed onto the queue as its own job and the caller continues right away. The action fails when the target workflow no longer exists or has no matching trigger; a target that is inactive is skipped silently.
Write to log
Writes a line to the application log.
| Setting | |
|---|---|
| Level | debug, info (default), notice, warning or error |
| Message | Placeholders allowed |
Lines are prefixed with [flow] , e.g. [flow] Order ORD-0042 changed to shipped.
Writing your own action
See Extending.
Next: Conditions.