---
title: "Publish validation — Workflows for Laravel | Roundly"
description: "Validate drafts and graphs before publishing — the ValidationReport, the eleven passes and every machine-readable validation code with its meaning."
url: https://roundly-consulting.com/open-source/docs/workflows-for-laravel/validation
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)

# Publish validation

```php
$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:

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