NewWe open-sourced 50+ Laravel packages
Custom AI apps, agents and automation — Roundly ConsultingRoundly
All packages
Workflows for Laravel

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.

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

MethodWrites
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.
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:

MethodNode
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):

MethodWrites
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

HelperJSON
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

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

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