Built-in steps
Workflows::catalogue()->export() returns every type with its label, config schema, outcomes, side-effect flag, the package it needs (bridged types) and whether it is available. php artisan workflows:catalogue prints the same as a table:
$export = Workflows::catalogue()->export(); // CatalogueExport{types, definitions}
$approval = $export->find('approval'); // CatalogueEntry
$approval->available; // false when approvals-for-laravel is missing
$approval->package; // 'roundly-consulting/approvals-for-laravel'
$approval->configSchema; // the node config's JSON Schema
$approval->outcomes; // list<Outcome>: approved, rejected, expired, cancelled (optional)| Type | Config | Outcomes | Side effects |
|---|---|---|---|
trigger | See Triggers. | next | no |
condition | {when: condition} | true, false | no |
branch | {cases: list<{outcome, when}>, default: "<outcome>"} | the case outcomes + default | no |
set_field | {field: "subject.<field>", value: literal | {ref}} | next | yes |
transition | {transition, lifecycle?, reason?} | transitioned, denied (optional) | yes |
delay | {for: duration} | {until: date-time} | {until_field: "subject.<date>", offset?: duration} | next | no |
wait | {event, match?: "subject"|"any", where?} | {condition, poll?} + timeout? | received / met (+ timed_out) | no |
loop | {max: 1…limits.max_loop_iterations} | continue, exhausted | no |
start_workflow | {definition, subject?: "same"|{field}|null, input?: map of values, wait?: bool} | started (no wait) / completed, failed, cancelled (optional) | yes |
end | {status?: "completed"|"failed", reason?} | — | no |
approval | Approvals bridge — see Bridges. | approved, rejected, expired, cancelled (optional) | yes |
notify | Notifications bridge. | next | yes |
webhook | {endpoint, event, payload?} — only with a DeliversWebhooks adapter | queued | yes |
trigger, condition, branch
- trigger — exactly one per graph. Completed at start; its output is {input, trigger} (the validated input and the trigger payload), readable as steps.<trigger id>.output.*.
- condition — evaluates a condition against the subject, input, trigger, owner and earlier outputs. Both edges are required. Pure; runs inside the engine’s bookkeeping transaction.
- branch — the first case whose when holds wins; otherwise default. Outcome keys match ^[a-z][a-z0-9_]{0,31}$ and are unique; every outcome needs an edge.
set_field and transition
- set_field writes a writable registered field (->field(..., writable: true)) of the subject. The value is validated against the field type. A relation path is refused at publish. Ability: the subject type’s ability() (default update), unless workflows.authorization.abilities.set_field overrides it. With the lifecycle bridge, a field backed by a lifecycle state column is refused at publish (field_not_writable) and at run time (field_write_refused).
- transition applies a lifecycle transition through Contracts\TransitionAdapter (the lifecycle bridge by default, or workflows.adapters.transition). Without either the type reports adapter_missing. A denied transition follows the denied edge when it exists; otherwise the step fails transition_denied:<code> (retried only when lifecycle says the denial is retryable).
// condition — both edges required
{ "id": "big", "type": "condition", "config": { "when": { "field": "subject.amount", "op": "gt", "value": "50000" } } }
// branch — the first case whose `when` holds wins, otherwise `default`
{ "id": "route", "type": "branch", "config": {
"cases": [
{ "outcome": "legal", "when": { "field": "subject.department", "op": "equals", "value": "legal" } },
{ "outcome": "finance", "when": { "field": "subject.department", "op": "equals", "value": "finance" } } ],
"default": "default" } }
// set_field — writes a writable registered field of the subject
{ "id": "mark", "type": "set_field", "config": { "field": "subject.priority", "value": { "ref": "input.priority" } } }
// transition — a lifecycle transition through the transition adapter
{ "id": "approve_state", "type": "transition", "config": { "transition": "approve", "reason": "Approved by the workflow" } }delay and wait
- delay — for adds a duration (calendar or business time); until waits for an instant; until_field waits for a date field of the subject plus an optional signed offset (a date field stored at midnight is due at midnight of that day in the version timezone). A due time in the past proceeds at once; a null field fails delay_field_empty. Only a positive for is a cycle breaker.
- wait on an event — resumes on Workflows::emit($key, $subject) (or signal()), outcome received. match subject (default) requires the same subject; any accepts any subject of the same owner — never across owners. Only events emitted after the step started waiting count. where (a condition over trigger.*, the event’s payload) keeps waiting until an event whose payload matches; where without event is refused at publish.
- wait on a condition — evaluated on entry first; then on every subject touch (HasWorkflows’ updated hook, Workflows::for($subject)->touch()) and every poll (≥ PT1M, default PT15M); outcome met.
- timeout adds the required timed_out outcome. wait supports escalations. Wake never evaluates a condition itself: it hands the poll to a job that restores the owner context first.
// delay — a positive `for` (calendar or business time), an instant, or a date field ± an offset
{ "id": "cool_off", "type": "delay", "config": { "for": { "amount": 2, "unit": "business_days" } } }
{ "id": "until", "type": "delay", "config": { "until": "2026-12-01T09:00:00+01:00" } }
{ "id": "remind", "type": "delay", "config": { "until_field": "subject.due_on", "offset": { "amount": -3, "unit": "days" } } }
// wait for an event — only a payload that matches `where` resumes it
{ "id": "signed", "type": "wait", "config": {
"event": "doc.signed", "match": "subject",
"where": { "field": "trigger.role", "op": "equals", "value": "cfo" },
"timeout": "P5D" } }
// wait until a condition holds — checked on every subject touch and every poll
{ "id": "paid", "type": "wait", "config": {
"condition": { "field": "subject.status", "op": "equals", "value": "paid" },
"poll": "PT15M", "timeout": "P14D" } }loop — the rework loop
A loop is a breaker through its continue edge only. The counter is the node’s visit count in the instance: continue while it is ≤ max, then exhausted — so an exhausted edge leading back into the same cycle guards nothing and is refused. The common “approval → rework → approval again” resubmission goes through a loop that caps the rounds. Each pass through approve is a new visit, so it opens a fresh round with a fresh idempotency key:
"nodes": [
{ "id": "start", "type": "trigger", "config": { "trigger": "event", "event": "purchase_request.submitted" } },
{ "id": "round", "type": "loop", "config": { "max": 3 } },
{ "id": "approve", "type": "approval", "config": { "approvers": [ { "kind": "field", "field": "subject.approver" } ], "rule": "any" } },
{ "id": "rework", "type": "wait", "config": { "event": "purchase_request.resubmitted", "timeout": "P14D" } },
{ "id": "done", "type": "end" },
{ "id": "gave_up", "type": "end", "config": { "status": "failed", "reason": "too many rounds" } }
],
"edges": [
{ "from": "start", "to": "round" },
{ "from": "round", "to": "approve", "outcome": "continue" },
{ "from": "round", "to": "gave_up", "outcome": "exhausted" },
{ "from": "approve", "to": "done", "outcome": "approved" },
{ "from": "approve", "to": "rework", "outcome": "rejected" },
{ "from": "approve", "to": "gave_up", "outcome": "expired" },
{ "from": "rework", "to": "round", "outcome": "received" },
{ "from": "rework", "to": "gave_up", "outcome": "timed_out" }
]start_workflow and end
// start_workflow — a manual definition of the same owner; wait for it or carry on
{ "id": "welcome", "type": "start_workflow", "config": {
"definition": "welcome",
"subject": "same", // "same" (default), { "field": … } or null
"input": { "employee": { "ref": "input.employee" } },
"wait": true } } // outcomes completed / failed / cancelled; without wait: started
// end — completes the instance, or fails it with ended_by_graph and the reason
{ "id": "done", "type": "end" }
{ "id": "gave_up", "type": "end", "config": { "status": "failed", "reason": "too many rounds" } }- start_workflow starts a manual definition of the same owner (depth + 1, ≤ limits.max_depth). subject: same (default), a party/relation field of the subject, or null; a subject-less child definition always gets no subject. input maps child inputs to values or refs.
- wait: false completes at once with started; wait: true waits for the child and resumes with completed, failed or cancelled. Idempotent: a retry never starts a second child.
- A child the start-rate limit (limits.max_starts_per_minute) refuses was never started, so the step is not completed: it fails retryably with child_not_started and retries with backoff, starting the child once the window has room.
- Publish checks the child exists, is manual, and its required inputs are covered (re-checked at run time against the child’s current version); a static self-start without wait: false is a warning.
- Cancelling the parent cancels the child it started — with or without wait, also a child started by an attempt that was still running, and the child of a step queued for a retry — only while it is still active, in the parent → child lock order.
- end completes the instance, or with status: failed fails it with ended_by_graph and the reason.
Bridged and adapter steps
approval (approvals-for-laravel) and notify (notifications-for-laravel) are covered under Bridges. A bridged type whose package is missing stays in the catalogue with available: false and its package name, and publish reports adapter_missing. The webhook step ({endpoint, event, payload?}, outcome queued) is registered only when workflows.adapters.webhooks names a Contracts\DeliversWebhooks implementation; the delivery runs once per visit through once(), and endpoint is owner-specific (cleared on template import).
Show your open-source love
This package is free and MIT-licensed. If it saves you time, a one-off donation or a Patreon membership keeps it maintained, tested and documented.
More ways to support, including cryptoBy donating, you agree to our donation terms.
Want this built into your product?
We integrate our packages into custom Laravel and AI builds. Tell us what you're working on and we'll reply within 48 hours.