---
title: "Built-in steps — Workflows for Laravel | Roundly"
description: "Every built-in step type — trigger, condition, branch, set_field, transition, delay, wait, loop, start_workflow, end, approval, notify, webhook."
url: https://roundly-consulting.com/open-source/docs/workflows-for-laravel/built-in-steps
language: en
---

[All packages](https://roundly-consulting.com/open-source.md)

[Workflows for Laravel](https://roundly-consulting.com/open-source/docs/workflows-for-laravel.md)

# 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:

```php
$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).

```jsonc
// 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.

```jsonc
// 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:

```jsonc
"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

```jsonc
// 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 crypto](https://roundly-consulting.com/support-us.md)

By donating, you agree to our [donation terms](https://roundly-consulting.com/donation-terms.md).

[Support our open source work (opens in a new tab)](https://donate.stripe.com/dRmeVe8FX5PF1Qd9pXcEw00) [Join us on Patreon (opens in a new tab)](https://www.patreon.com/cw/roundly)

## 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.

[Get a quote in 48 hours](https://roundly-consulting.com/contact.md) [Browse all packages](https://roundly-consulting.com/open-source/docs/workflows-for-laravel.md)
