Publish validation
$report = Workflows::definition('purchase-approval', $team)->validate(); // the draft
$report = Workflows::versions()->validate($graph, 'purchase_request', $team); // any graph
$report->valid(); // no errors (warnings don't block)
$report->errors; // list<ValidationError>
$report->errorsOnly(); // and ->warnings()
$report->find(ValidationCode::UnknownField, 'check');
$report->has(ValidationCode::UnknownField, 'check');
$report->toArray();ValidationError{code: ValidationCode, severity: error|warning, message (translated), node: ?string, edge: ?{from, to, outcome}, path: ?string (JSON pointer), params: array}. Messages never contain a class name. publish() runs the full validation under the definition lock and throws InvalidGraphException (carrying the report) on errors; its message lists the first problems. Saving a draft checks structure only:
use RoundlyConsulting\Workflows\Exceptions\InvalidGraphException;
try {
Workflows::definition('purchase-approval', $team)->publish(by: $admin);
} catch (InvalidGraphException $e) {
$e->validationReport()->toArray(); // [{code, severity, message, node, edge, path, params}, …]
}Passes
All passes run and collect errors; only a structural failure stops early:
- Structure: schema version, node/edge shape, id pattern, duplicate ids, dangling edges, size caps.
- Catalogue: known node types; a bridged type whose package is missing → adapter_missing with params.package.
- Config: the type’s JSON schema (invalid_config + path), then its semantic checks, then escalation blocks.
- Trigger/end: exactly one trigger, at least one end, no edge into the trigger, none out of an end.
- Outcomes: each required outcome has exactly one edge; no unknown or duplicate outcomes.
- Reachability: every node is reachable from the trigger.
- Cycles: with the guarded edges removed — a loop’s continue edge and the way out of a delay with a positive for — no cycle remains.
- Fields and refs: field paths, operators, value types, writability, inputs, ancestor refs, the subject’s owner resolution.
- Actor: on_behalf_of needs a trigger with a user.
- Import flags: unresolved template review flags.
- Cross-definition: start_workflow targets exist for the owner, are manual, and get their required inputs; a static self-start without wait: false is a warning.
Codes
| Code | Meaning |
|---|---|
invalid_schema_version | schema is not roundly.workflows.graph/v1 |
invalid_structure | The document’s shape is wrong |
invalid_node_id | An id breaks ^[a-z][a-z0-9_]{0,63}$ (the message never repeats the submitted id) |
duplicate_node | Two nodes share an id |
dangling_edge | An edge points at an unknown node |
graph_too_large | Over a size cap |
unknown_node_type | No step type with that key |
adapter_missing | The type needs a package that isn’t installed |
invalid_config | The config breaks the type’s schema or rules |
trigger_count | Not exactly one trigger |
no_end | No end node |
trigger_has_incoming | An edge leads into the trigger |
end_has_outgoing | An edge leaves an end |
missing_outcome | A required outcome has no edge |
unknown_outcome | An edge names an outcome the type doesn’t have |
duplicate_outcome | Two edges for one (from, outcome) |
outcome_label_required | An unlabelled edge from a node whose outcomes are not just next (params: outcome, the type’s outcomes) |
unreachable_node | A node can’t be reached from the trigger |
cycle_without_breaker | A cycle passes through no loop continue edge and no positive for delay (params.nodes) |
unknown_field | A field path isn’t registered |
operator_not_allowed | The operator doesn’t suit the field type |
invalid_value | A literal of the wrong type |
unknown_input | input.* isn’t declared |
invalid_reference | steps.<id> isn’t an ancestor |
field_not_writable | set_field on a non-writable or lifecycle-state field |
subject_owner_unresolvable | An owned definition over a subject type without owner resolution |
unknown_subject_type | The definition’s subject type isn’t registered |
actor_requires_user | on_behalf_of with a schedule or webhook trigger |
unresolved_import_reference | A template review flag is still open |
unknown_definition | A start_workflow target doesn’t exist for the owner |
child_trigger_not_manual | A start_workflow target isn’t manual |
missing_child_input | A required child input isn’t mapped |
self_start | Warning: a definition starts itself |
unknown_calendar | A calendar name isn’t known for the owner |
roles_unavailable | A role party without a roles resolver |
invalid_cron | A cron expression the parser refuses (params.token) |
condition_too_complex | Too deep or too many clauses |
unknown_transition | Lifecycle bridge: no such transition |
unknown_notification_type | Notifications bridge: no such type |
unknown_form | A form trigger names a form forms-for-laravel doesn’t have (without forms installed: adapter_missing) |
Input from the browser is untrusted: validation and draft saves answer any document — wrong types at every level, deep nesting, huge strings — with a ValidationReport or InvalidGraphException, never a TypeError, and no error message echoes a submitted value.
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.