Five complete workflows, each a file in the template envelope that workflows:sync reads (or versions()->import() imports). Every recipe is also a test fixture of the package, published and driven to its documented end state. Because they are the fixtures verbatim, every recipe uses the test app’s one registered subject type, "subject_type": "document", with its title and amount fields (so the SLA recipe’s “resolved” check reads subject.title), and the onboarding and nightly recipes use an acme.create_task host step. In your app, set subject_type to the key you registered for the real model (for example purchase_request or ticket) and point the field refs at that type’s registered fields; publish validation reports any field the subject type doesn’t have.
Purchase approval with escalation
A submitted purchase request over 5,000 goes to its approver; after two business days without a decision the approval is reassigned to the approver’s manager, and it expires after five. Small requests skip the approval. Needs approvals-for-laravel and notifications-for-laravel:
{
"schema": "roundly.workflows.template/v1",
"definition": {
"key": "purchase-approval",
"name": "Purchase approval",
"subject_type": "document",
"description": null
},
"graph": {
"schema": "roundly.workflows.graph/v1",
"settings": {"timezone": "Europe/Bratislava", "calendar": "weekdays"},
"nodes": [
{
"id": "start",
"type": "trigger",
"config": {"trigger": "event", "event": "purchase_request.submitted"}
},
{
"id": "big",
"type": "condition",
"config": {"when": {"field": "subject.amount", "op": "gt", "value": "5000"}}
},
{
"id": "approve",
"type": "approval",
"config": {
"approvers": [{"kind": "field", "field": "subject.approver"}],
"rule": "any",
"expires_in": {"amount": 5, "unit": "business_days"}
},
"escalations": [
{
"after": {"amount": 2, "unit": "business_days"},
"actions": [
{
"type": "reassign",
"to": [{"kind": "field", "field": "subject.approver_manager"}]
}
]
}
]
},
{
"id": "mark",
"type": "set_field",
"config": {"field": "subject.priority", "value": "approved"}
},
{
"id": "tell",
"type": "notify",
"config": {
"notification": "purchase.rejected",
"recipients": [{"kind": "field", "field": "subject.requester"}]
}
},
{"id": "done", "type": "end"}
],
"edges": [
{"from": "start", "to": "big"},
{"from": "big", "to": "approve", "outcome": "true"},
{"from": "big", "to": "mark", "outcome": "false"},
{"from": "approve", "to": "mark", "outcome": "approved"},
{"from": "approve", "to": "tell", "outcome": "rejected"},
{"from": "approve", "to": "tell", "outcome": "expired"},
{"from": "mark", "to": "done"},
{"from": "tell", "to": "done"}
]
}
}The rework loop
A cycle is legal only through a delay or loop node, so “approval → rework → approval again” goes through a loop that caps the rounds. Each pass through approve opens a fresh round with a fresh idempotency key. Needs approvals-for-laravel:
{
"schema": "roundly.workflows.template/v1",
"definition": {"key": "rework-loop", "name": "Rework loop", "subject_type": "document", "description": null},
"graph": {
"schema": "roundly.workflows.graph/v1",
"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"}
]
}
}Onboarding checklist
A subject-less manual workflow: two host steps and a child workflow it waits for. The child, welcome, is any subject-less manual definition of the same owner; start_workflow refuses one whose required inputs it doesn’t cover:
{
"schema": "roundly.workflows.template/v1",
"definition": {
"key": "onboarding",
"name": "Onboarding checklist",
"subject_type": null,
"description": null
},
"graph": {
"schema": "roundly.workflows.graph/v1",
"nodes": [
{
"id": "start",
"type": "trigger",
"config": {
"trigger": "manual",
"inputs": [{"name": "employee", "type": "string", "label": "Employee", "required": true}]
}
},
{"id": "laptop", "type": "acme.create_task", "config": {"title": "Order a laptop"}},
{"id": "accounts", "type": "acme.create_task", "config": {"title": "Create the accounts"}},
{
"id": "welcome",
"type": "start_workflow",
"config": {"definition": "welcome", "subject": null, "wait": true}
},
{"id": "done", "type": "end"},
{
"id": "failed",
"type": "end",
"config": {"status": "failed", "reason": "The welcome did not finish"}
}
],
"edges": [
{"from": "start", "to": "laptop"},
{"from": "laptop", "to": "accounts"},
{"from": "accounts", "to": "welcome"},
{"from": "welcome", "to": "done", "outcome": "completed"},
{"from": "welcome", "to": "failed", "outcome": "failed"}
]
}
}SLA reminder
Waits until the ticket is resolved — re-checked whenever the subject changes (the HasWorkflows trait) and every 15 minutes — and escalates after four business hours by starting a sla-chase workflow. After five days the SLA is breached:
{
"schema": "roundly.workflows.template/v1",
"definition": {
"key": "sla-reminder",
"name": "SLA reminder",
"subject_type": "document",
"description": null
},
"graph": {
"schema": "roundly.workflows.graph/v1",
"settings": {"timezone": "UTC", "calendar": "weekdays"},
"nodes": [
{
"id": "start",
"type": "trigger",
"config": {"trigger": "event", "event": "ticket.opened"}
},
{
"id": "resolved",
"type": "wait",
"config": {
"condition": {"field": "subject.title", "op": "equals", "value": "Resolved"},
"poll": "PT15M",
"timeout": "P5D"
},
"escalations": [
{
"after": {"amount": 4, "unit": "business_hours"},
"actions": [{"type": "start_workflow", "definition": "sla-chase"}]
}
]
},
{"id": "done", "type": "end"},
{
"id": "breached",
"type": "end",
"config": {"status": "failed", "reason": "SLA breached"}
}
],
"edges": [
{"from": "start", "to": "resolved"},
{"from": "resolved", "to": "done", "outcome": "met"},
{"from": "resolved", "to": "breached", "outcome": "timed_out"}
]
}
}Nightly schedule
A subject-less schedule: every night at 02:00 Bratislava time, one instance. Missed nights within schedule.catch_up start once; a repeated DST hour runs once:
{
"schema": "roundly.workflows.template/v1",
"definition": {"key": "nightly", "name": "Nightly clean-up", "subject_type": null, "description": null},
"graph": {
"schema": "roundly.workflows.graph/v1",
"nodes": [
{
"id": "start",
"type": "trigger",
"config": {"trigger": "schedule", "cron": "0 2 * * *", "timezone": "Europe/Bratislava"}
},
{"id": "report", "type": "acme.create_task", "config": {"title": "Nightly report"}},
{"id": "done", "type": "end"}
],
"edges": [{"from": "start", "to": "report"}, {"from": "report", "to": "done"}]
}
}Driving them in a test
uses(InteractsWithWorkflows::class);
$this->fakeStep('approval', fn (StepContext $c) => StepResult::complete($c->visit() === 1 ? 'rejected' : 'approved'),
outcomes: ['approved', 'rejected', 'expired']);
Workflows::definitions()->sync(resource_path('workflows'), publish: true);
Workflows::emit('purchase_request.submitted', $request);
Workflows::emit('purchase_request.resubmitted', $request);
expect($request->workflowInstances()->sole())->toHaveCompletedVia(['start', 'round', 'approve', 'rework', 'round', 'approve', 'done']);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.