NewWe open-sourced 50+ Laravel packages
Custom AI apps, agents and automation — Roundly ConsultingRoundly
All packages
Workflows for Laravel

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 needsCallRender with
The document formatWorkflows::schema()->graph(), ->template(), ->definitions()JSON Schema (draft 2020-12)
The palette of stepsWorkflows::catalogue()->export()CatalogueEntryResource::collection($export->types)
Each step’s formCatalogueEntry::$configSchemaAny JSON-schema form renderer; #/$defs from schema()->definitions()
The outcomes a node exposes (edge labels)CatalogueEntry::$outcomesMay depend on config: recompute after edits.
Field pickersWorkflows::subjects()->export($subjectType)SubjectTypeResource
CalendarsWorkflows::calendars()->names()—
Mapped eventsWorkflows::triggers()->events()—
Load / save the draftdefinition($key, $owner)->draft(), ->edit($graph, $expectedChecksum)VersionResource
Live validationdefinition(...)->validate() / versions()->validate($graph, $subjectType, $owner)ValidationReportResource (errors carry node, edge, path)
Publishdefinition(...)->publish(by: $user)InvalidGraphException → its report
Compare versionsversions()->diff($from, $to)VersionDiffResource
Test modedefinition(...)->dryRun()->sample([...])->assume([...])->run() / ->explore()DryRunResultResource
Live run overlayversions()->overlay($version)OverlayResource
History and step logdefinition(...)->instances(), instances()->steps($instance)InstanceResource, StepRunResource
One run in detailinstances()->detail($instance)InstanceResource (steps, waits, escalations)
Copy between ownersversions()->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']));
ResourceWrapsFields
DefinitionResourceWorkflowDefinitionid, key, name, description, subject_type, status {value, label}, owner, current_version, draft, created_at, updated_at
VersionResourceWorkflowVersionid, number, status, graph, trigger, actor, checksum, review_flags, published_at, published_by
InstanceResourceWorkflowInstanceid (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
WaitResourceWorkflowWaitkind {value, label}, due_at, event, status
EscalationResourceWorkflowEscalationlevel, due_at, status, fired_at
StepRunResourceStepRunnode, type, visit, attempt, status, outcome, input, output, error {code, message, retryable}, scheduled_for, started_at, finished_at
CatalogueEntryResourceCatalogueEntrykey, label, description, category, config_schema, output_schema, outcomes [{key, label, required}], side_effects, escalations, available, package
SubjectTypeResourceSubjectTypeDefinitionkey, label, owner_type, fields [{name, type, label, writable, options}]
ValidationReportResourceValidationReportvalid, errors [{code, severity, message, node, edge {from, to, outcome}|null, path, params}]
VersionDiffResourceVersionDifffrom, to, identical, nodes {added, removed, changed, moved}, edges {added, removed, relabelled}, trigger, settings
OverlayResourceOverlayReportversion, total, nodes [{node, running, waiting, failed}]
DryRunResultResourceDryRunResultvalid, started, reason, path, actions, ended_at, ended_by, report
TemplateResourceTemplatePayloadschema, 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 keyDefault classReceives
workflows.resources.ownerWorkflowOwnerResourceThe owner model.
workflows.resources.subjectWorkflowSubjectResourceThe subject model.
workflows.resources.userWorkflowUserResourceA 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());
}
HTTPExceptions
404UnknownDefinitionException, NoVersionException, SubjectOwnerMismatchException
409DefinitionNotActiveException, DefinitionKeyTakenException, DraftConflictException, NothingToPublishException, PublishedVersionImmutableException, TriggerMismatchException, InstanceNotRetryableException, InstanceNotCancellableException
422InvalidGraphException, InvalidTriggerInputException, InvalidDefinitionException, InvalidEventKeyException, InvalidTemplateException, InvalidDiffException, MissingIdempotencyKeyException, MissingTriggeringUserException, SubjectTypeMismatchException, UnknownSubjectTypeException, UnknownStepTypeException, UnknownCalendarException, BusinessCalendarNeverOpenException
403StepAuthorizationDeniedException
500DuplicateStepTypeException, 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

By 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.