---
title: "Building a visual editor — Workflows for Laravel | Roundly"
description: "What a visual builder calls — schemas, step catalogue, field pickers, live validation, diffs, dry runs and the overlay — and the JSON resources to render."
url: https://roundly-consulting.com/open-source/docs/workflows-for-laravel/visual-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)

# Building a visual editor

The package ships no UI. It gives a builder everything it needs to draw, edit, check, compare, preview and monitor a workflow. Wire these behind your own controllers and authorization (the owner’s admins only):

| The builder needs | Call | Render with |
| --- | --- | --- |
| The document format | `Workflows::schema()->graph(), ->template(), ->definitions()` | JSON Schema (draft 2020-12) |
| The palette of steps | `Workflows::catalogue()->export()` | CatalogueEntryResource::collection($export->types) |
| Each step’s form | `CatalogueEntry::$configSchema` | Any JSON-schema form renderer; #/$defs from schema()->definitions() |
| The outcomes a node exposes (edge labels) | `CatalogueEntry::$outcomes` | May depend on config: recompute after edits. |
| Field pickers | `Workflows::subjects()->export($subjectType)` | SubjectTypeResource |
| Calendars | `Workflows::calendars()->names()` | — |
| Mapped events | `Workflows::triggers()->events()` | — |
| Load / save the draft | `definition($key, $owner)->draft(), ->edit($graph, $expectedChecksum)` | VersionResource |
| Live validation | `definition(...)->validate() / versions()->validate($graph, $subjectType, $owner)` | ValidationReportResource (errors carry node, edge, path) |
| Publish | `definition(...)->publish(by: $user)` | InvalidGraphException → its report |
| Compare versions | `versions()->diff($from, $to)` | VersionDiffResource |
| Test mode | `definition(...)->dryRun()->sample([...])->assume([...])->run() / ->explore()` | DryRunResultResource |
| Live run overlay | `versions()->overlay($version)` | OverlayResource |
| History and step log | `definition(...)->instances(), instances()->steps($instance)` | InstanceResource, StepRunResource |
| One run in detail | `instances()->detail($instance)` | InstanceResource (steps, waits, escalations) |
| Copy between owners | `versions()->export() / versions()->import($payload, $owner) + resolveFlag()` | TemplateResource |

- Two tabs: send the checksum the editor loaded as $expectedChecksum; a stale save throws DraftConflictException (409).
- Half-finished graphs save fine; only publish validates fully.
- Positions and labels go in each node’s ui object — kept verbatim, ignored by the engine, reported as moved by the diff.
- Node ids are the builder’s (^\[a-z\]\[a-z0-9\_\]{0,63}$); imports remap them (ImportResult::$idMap).
- Validation errors point at a node, an edge or a JSON pointer inside the node’s config, so the builder can highlight the exact field. Messages are translated (en, sk) and contain no class names.
- No class names anywhere: subject types are registry keys, references render through the reference resources.

## JSON resources

The builder and admin API is yours: use the manager plus these resources (RoundlyConsulting\\Workflows\\Http\\Resources), behind your own authorization:

```php
use RoundlyConsulting\Workflows\Http\Resources\{CatalogueEntryResource, InstanceResource, ValidationReportResource};

// The palette and a step's form
return CatalogueEntryResource::collection(Workflows::catalogue()->export()->types);

// Live validation
return new ValidationReportResource(Workflows::versions()->validate($request->input('graph'), 'purchase_request', $team));

// One run in detail: steps, waits and escalations in a fixed number of queries
$instance = Workflows::instances()->find($ulid) ?? abort(404);
Workflows::instances()->detail($instance);

return new InstanceResource($instance->loadMissing(['subject', 'owner', 'starter']));
```

| Resource | Wraps | Fields |
| --- | --- | --- |
| `DefinitionResource` | WorkflowDefinition | id, key, name, description, subject\_type, status {value, label}, owner, current\_version, draft, created\_at, updated\_at |
| `VersionResource` | WorkflowVersion | id, number, status, graph, trigger, actor, checksum, review\_flags, published\_at, published\_by |
| `InstanceResource` | WorkflowInstance | id (ULID), definition, version, status, subject, subject\_type, owner, trigger {type, key}, started\_by, current\_node, steps\_count, depth, failure {code, message, node}, started\_at, ended\_at; when loaded: steps, waits, escalations |
| `WaitResource` | WorkflowWait | kind {value, label}, due\_at, event, status |
| `EscalationResource` | WorkflowEscalation | level, due\_at, status, fired\_at |
| `StepRunResource` | StepRun | node, type, visit, attempt, status, outcome, input, output, error {code, message, retryable}, scheduled\_for, started\_at, finished\_at |
| `CatalogueEntryResource` | CatalogueEntry | key, label, description, category, config\_schema, output\_schema, outcomes \[{key, label, required}\], side\_effects, escalations, available, package |
| `SubjectTypeResource` | SubjectTypeDefinition | key, label, owner\_type, fields \[{name, type, label, writable, options}\] |
| `ValidationReportResource` | ValidationReport | valid, errors \[{code, severity, message, node, edge {from, to, outcome}\|null, path, params}\] |
| `VersionDiffResource` | VersionDiff | from, to, identical, nodes {added, removed, changed, moved}, edges {added, removed, relabelled}, trigger, settings |
| `OverlayResource` | OverlayReport | version, total, nodes \[{node, running, waiting, failed}\] |
| `DryRunResultResource` | DryRunResult | valid, started, reason, path, actions, ended\_at, ended\_by, report |
| `TemplateResource` | TemplatePayload | schema, definition {key, name, subject\_type, description}, graph, review \[{node, path, reason}\], exported\_at, source {checksum} |

- definition, version, subject, owner, started\_by and published\_by render only from loaded relations or the stored morph pair: a collection never queries per row. Eager-load what you need: ->with(\['definition', 'version', 'subject', 'owner', 'starter'\]).
- Workflows::instances()->detail($instance) loadMissing()s the definition, the version, the step log (in order), the pending waits and the pending escalations (by due time), so InstanceResource renders steps, waits and escalations in a fixed number of queries. Without detail() those three keys are left out.
- Unknown exception messages never go over the wire: for exception or worker\_timeout the message is the translated workflows::failures.exception; the raw message stays in the database for operators.
- output.request\_id of an approval step is the round to decide within().

## Reference resources — no class names over the wire

Host models (owner, subject, user) render through a reference resource — by default only {id, type: class\_basename}, never a morph class or a class name. A deleted subject renders from its stored pair, no query:

| Config key | Default class | Receives |
| --- | --- | --- |
| `workflows.resources.owner` | `WorkflowOwnerResource` | The owner model. |
| `workflows.resources.subject` | `WorkflowSubjectResource` | The subject model. |
| `workflows.resources.user` | `WorkflowUserResource` | A user (started\_by, published\_by, dry-run requester). |

```php
final class TeamReferenceResource extends JsonResource
{
    public function toArray(Request $request): array
    {
        return ['id' => $this->resource->getKey(), 'name' => $this->resource->name];
    }
}

// config/workflows.php
'resources' => ['owner' => TeamReferenceResource::class, ...],
```

A blank value falls back to the default; anything that isn’t an instantiable JsonResource subclass (the bare base included) throws InvalidWorkflowsConfigurationException naming the key.

## Error → HTTP mapping

Every package exception extends WorkflowsException with errorCode(), status() and context(); the full list is under Exceptions:

```php
try {
    $result = $workflows->start($key, $subject, $owner)->input($request->all())->by($request->user())->dispatch();
} catch (WorkflowsException $e) {
    return response()->json(['code' => $e->errorCode(), 'message' => $e->getMessage()], $e->status());
}
```

| HTTP | Exceptions |
| --- | --- |
| 404 | UnknownDefinitionException, NoVersionException, SubjectOwnerMismatchException |
| 409 | DefinitionNotActiveException, DefinitionKeyTakenException, DraftConflictException, NothingToPublishException, PublishedVersionImmutableException, TriggerMismatchException, InstanceNotRetryableException, InstanceNotCancellableException |
| 422 | InvalidGraphException, InvalidTriggerInputException, InvalidDefinitionException, InvalidEventKeyException, InvalidTemplateException, InvalidDiffException, MissingIdempotencyKeyException, MissingTriggeringUserException, SubjectTypeMismatchException, UnknownSubjectTypeException, UnknownStepTypeException, UnknownCalendarException, BusinessCalendarNeverOpenException |
| 403 | StepAuthorizationDeniedException |
| 500 | DuplicateStepTypeException, InvalidStepTypeException |

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