---
title: "Graph builder in PHP — Workflows for Laravel | Roundly"
description: "Workflows::graph() writes the same JSON graph in PHP — every step helper, modifier, edge method and value helper (When, Ref, Party, After, Trigger, Stage)."
url: https://roundly-consulting.com/open-source/docs/workflows-for-laravel/graph-builder
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)

# Graph builder in PHP

Workflows::graph() writes the same roundly.workflows.graph/v1 document a visual builder stores, in PHP — for seeders, tests, app-wide system workflows and definition files. It only builds the document: validation stays with validate() and publish, so a duplicate id or a missing edge is reported there, never hidden. It is pure — under Workflows::fake() it passes through and records nothing.

```php
use RoundlyConsulting\Workflows\Facades\Workflows;
use RoundlyConsulting\Workflows\Graph\Build\{After, Escalate, Party, Stage, Trigger, When};

$graph = Workflows::graph()
    ->settings(timezone: 'Europe/Bratislava', calendar: 'weekdays')
    ->trigger('start', Trigger::event('purchase_request.submitted'))
    ->condition('big', When::field('subject.amount')->gt('50000'))
    ->approval('legal_cfo', stages: [
        Stage::named('Legal')->from(Party::role('legal'))->any(),
        Stage::named('CFO')->from(Party::role('cfo'))->any(),
    ])->escalate(After::businessDays(2), Escalate::reassign(Party::field('subject.approver_manager')))
    ->end('done')
    ->connect('start', 'big')
    ->connect('big', 'legal_cfo', on: 'true')
    ->connect('big', 'done', on: 'false')
    ->connect('legal_cfo', 'done', on: ['approved', 'rejected', 'expired']);

Workflows::definition('purchase-approval', $team)->edit($graph);   // or $graph->toArray() / json_encode($graph)
$graph->validate(subjectType: 'purchase_request', owner: $team);   // the same ValidationReport as versions()->validate()
```

Everything that takes a graph array takes a builder: DefinitionHandle::edit(), VersionsAccessor::validate() and VersionsAccessor::dryRun(). GraphBuilder is Macroable, Arrayable, JsonSerializable and Stringable (its JSON). toArray() always starts with "schema": "roundly.workflows.graph/v1", keeps the node order, and leaves out empty settings (unless the document it was loaded from had them).

## The document

| Method | Writes |
| --- | --- |
| `settings(?string $actor, ?string $timezone, ?string $calendar, ?int $maxSteps, ?RetryPolicy $retry)` | settings.{actor, timezone, calendar, limits.max\_steps, retry} — merged; null arguments leave keys out. |
| `node(string $id, string $type, array $config = [])` | Any node — for host step types. |
| `remove(string $id)` | Removes the node and every edge touching it. |
| `GraphBuilder::from(array\|WorkflowVersion $graph)` | Loads a document (or a version’s graph) to change it; from($g)->toArray() equals $g by content. |

```php
use RoundlyConsulting\Workflows\Graph\Build\{After, GraphBuilder, Ref};

$graph = GraphBuilder::from($definition->currentVersion)   // or an array; from($g)->toArray() equals $g by content
    ->remove('notify_legal')                                // removes the node and every edge touching it
    ->setField('mark', 'subject.priority', Ref::input('priority'))
    ->retry(5, [10, 60, 300])                                // modifiers apply to the last node added
    ->ui(320, 80, 'Mark priority')
    ->delay('cool_off', After::hours(4))
    ->chain('mark', 'cool_off', 'done');                     // outcome-less edges: next only
```

## Steps

Each adds a node and makes it the “last node” for the modifiers. approval() and notify() are plain config — the type is available once its package is installed:

| Method | Node |
| --- | --- |
| `trigger($id, Trigger $trigger)` | {"type": "trigger", "config": …} |
| `condition($id, When $when)` | condition — outcomes true / false |
| `branch($id, array<string, When> $cases, string $default = 'default')` | branch — cases: \[{outcome, when}\], default |
| `setField($id, string $field, mixed $value)` | set\_field — {field, value} (a literal or a Ref) |
| `transition($id, string $transition, ?string $lifecycle, ?string $reason)` | transition |
| `delay($id, After\|CarbonInterface\|string $for)` | delay — for (an After or an ISO duration like PT2H) or until (an instant) |
| `delayUntilField($id, string $field, ?After $offset)` | delay — {until\_field, offset?} |
| `waitForEvent($id, string $event, string $match = 'subject', ?After $timeout, ?When $where)` | wait — {event, match?, timeout?, where?} |
| `waitUntil($id, When $when, ?After $poll, ?After $timeout)` | wait — {condition, poll?, timeout?} |
| `loop($id, int $max)` | loop |
| `startWorkflow($id, string $definition, array\|string\|null $subject = 'same', array $input = [], bool $wait = false)` | start\_workflow — subject left out for same |
| `end($id, ?string $status, ?string $reason)` | end (no config unless status / reason) |
| `approval($id, list<Party> $approvers = [], string $rule = 'any', list<Stage> $stages = [], ?After $expiresIn, ?string $preset)` | approval — {approvers, rule} (single stage), stages, preset, expires\_in |
| `notify($id, string $notification, Party\|list<Party> $to, array $data = [])` | notify — {notification, recipients, data?} |

## Modifiers and edges

A modifier before any node throws LogicException (a developer error, not a domain exception):

| Method | Writes |
| --- | --- |
| `retry(int $maxAttempts, ?list<int> $backoff)` | retry: {max\_attempts, backoff?} |
| `escalate(After $after, Escalate ...$actions)` | Appends escalations\[\]: {after, actions} — one level per call. |
| `ui(int $x, int $y, ?string $label)` | ui: {x, y, label?} — the engine never reads it. |
| `connect(string $from, string $to, string\|list<string>\|null $on = null)` | One edge, or one per outcome for a list. |
| `chain(string ...$ids)` | Outcome-less edges along the list — they stand for next, so connect a node with any other outcome with connect(…, on: …). |

## Values

| Helper | JSON |
| --- | --- |
| `When::field('subject.amount')->gt('50000')` | {"field": "subject.amount", "op": "gt", "value": "50000"} |
| `->equals(v), ->in([…]), ->lt(v), ->between(a, b), ->isEmpty(), ->contains(v, insensitive: true), ->before(v), ->after(v)` | The condition operators (between → \[a, b\], isEmpty → op: empty without a value, insensitive → case: insensitive). |
| `When::all(…), When::any(…), When::not(When)` | {"all": \[…\]}, {"any": \[…\]}, {"not": {…}} |
| `Ref::subject('amount'), Ref::owner('plan'), Ref::input('reason'), Ref::trigger('payload.amount'), Ref::step('approve', 'request_id'), Ref::now(), Ref::path('…')` | {"ref": "subject.amount"}, …, {"ref": "steps.approve.output.request\_id"}, {"ref": "now"} |
| `Ref::relative(After::days(-7))` | {"relative": {"amount": -7, "unit": "days"}} |
| `Party::role('legal'), Party::field('subject.approver'), Party::user($user\|7), Party::triggeredBy()` | {"kind": "role", "role": "legal"}, {"kind": "field", …}, {"kind": "user", "id": 7}, {"kind": "triggered\_by"} |
| `After::minutes\|hours\|days\|weeks\|months(n)` | {"amount": n, "unit": "days"} |
| `After::businessMinutes\|businessHours\|businessDays(n, ?string $calendar)` | {"amount": n, "unit": "business\_days", "calendar"?: …} |
| `After::iso('P2D')` | "P2D" |
| `Trigger::manual(Input ...$inputs), Trigger::form('purchase')` | {"trigger": "manual", "inputs"?: \[…\]}, {"trigger": "manual", "form": "purchase"} |
| `Trigger::event('doc.signed', ?When $condition)` | {"trigger": "event", "event": …, "condition"?: …} |
| `Trigger::schedule(?string $cron, ?array $preset, ?string $timezone), Trigger::webhook(?string $idempotencyFrom)` | {"trigger": "schedule", …}, {"trigger": "webhook", "idempotency"?: {"from": …}} |
| `->when(When $condition), ->forEach(?When $where, ?int $limit)` | Adds condition to any trigger; adds for\_each to a schedule. |
| `Input::string\|integer\|decimal\|date($name, ?label, required, rules), Input::boolean($name, ?label, required), Input::choice($name, $options, ?label, required)` | {"name", "type", "label"?, "required"?, "options"?, "rules"?} (choice → type: enum) |
| `Stage::named('Legal')->from(Party ...)->any()\|unanimous()\|quorum(n)\|weighted(n)` | {"name", "approvers", "rule", "quorum"?, "required"?} |
| `Escalate::reassign(Party ...), Escalate::notify('purchase.escalated', Party ...), Escalate::startWorkflow('chase')` | {"type": "reassign", "to": \[…\]}, {"type": "notify", "notification", "recipients"}, {"type": "start\_workflow", "definition"} |

Config values are written as plain JSON: builder values become their arrays, dates ISO-8601 strings, enums their value, models their key. Every helper renders itself — When, Ref, Party, Trigger, Input, Stage and Escalate have toArray(), After has toValue(), and Party::list() renders a party list — so use them inside node($id, $type, $config) for host step configs.

## Host macros

```php
use RoundlyConsulting\Workflows\Graph\Build\{GraphBuilder, Party, Trigger};

GraphBuilder::macro('task', fn (string $id, string $title) => $this->node($id, 'acme.create_task', ['title' => $title]));

Workflows::graph()->trigger('start', Trigger::manual())->task('order', 'Order')->end('done')->chain('start', 'order', 'done');

// Host step configs take the value helpers too — they are rendered for you:
Workflows::graph()->node('assign', 'acme.create_task', ['title' => 'Review', 'assignee' => Party::field('subject.owner')]);
```

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