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