Definitions and versions
Definitions
$definition = Workflows::definitions()->create(
key: 'purchase-approval', name: 'Purchase approval',
subjectType: 'purchase_request', owner: $team, description: null,
); // or createWith(CreateDefinitionData)
Workflows::definitions()->find('purchase-approval', $team);
Workflows::definitions()->exists('purchase-approval', $team);
Workflows::definitions()->all($team);
Workflows::definitions()->restore('purchase-approval', $team); // un-trash a soft-deleted definition- A new definition is draft, with version 1 as a draft holding the minimal graph (manual trigger → end).
- Keys match ^[a-z0-9][a-z0-9._-]{0,99}$ and are unique per owner (app-wide definitions share one scope). A trashed definition still holds its key: create() throws DefinitionKeyTakenException and restore() brings it back.
Drafts and publishing
$handle = Workflows::definition('purchase-approval', $team);
$handle->model(); // WorkflowDefinition (UnknownDefinitionException if missing)
$handle->draft(); // the draft version or null
$handle->edit($graph, $expectedChecksum); // save the draft (a graph array or a GraphBuilder; created as a copy of the current version)
$handle->discardDraft();
$handle->validate(); // ValidationReport of the draft
$handle->publish(by: $admin); // the draft becomes the current version
$handle->retire(); // no new starts; running instances finish
$handle->activate(); // allow starts again
$handle->resolveFlag($node, $path); // dismiss a template review flag
$handle->rotateWebhook(); // a new webhook id
$handle->versions(); // every version, by number
$handle->version(3);
$handle->instances(InstanceStatus::Failed, perPage: 25); // cursor-paginated history
$handle->start($subject); // PendingStart
$handle->dryRun(?int $version); // PendingDryRun (NoVersionException for a missing version)
$handle->export(?int $version); // TemplatePayload (NoVersionException for a missing version)- One draft per definition, enforced under the definition row lock. edit() with an $expectedChecksum that differs from the draft’s throws DraftConflictException — two builder tabs can’t overwrite each other.
- Published versions are immutable. The graph, trigger, actor, settings, checksum and publisher can’t change once published_at is set (PublishedVersionImmutableException). Only the status (published → superseded) and the schedule cursor move.
- publish() validates fully, freezes the draft, supersedes the previous version, sets the current version, activates the definition, starts the schedule cursor at now and fires VersionPublished after commit. No draft → NothingToPublishException. Publishing a retired definition re-activates it.
- Running instances keep their version. Resume, retry and escalations always use the instance’s own graph. A start runs on the version that is current once it holds the definition lock: one racing a publish starts on the new version.
- Retired definitions refuse every start (DefinitionNotActiveException), also one that was already under way when the definition was retired.
- Starting from a subject — Workflows::for($request)->start($key) and $request->workflows() — looks up the subject owner’s definition first and falls back to the app-wide one with that key. With an explicit owner, or through Workflows::start($key, $subject, $owner), only that scope is searched.
Definitions as files
App-wide system workflows (and seeds for new tenants) can live in your repository as JSON files in the template envelope — what workflows:export writes — and be synced on deploy:
php artisan workflows:export purchase-approval --path=resources/workflows # writes purchase-approval.json
php artisan workflows:validate --file=resources/workflows/purchase-approval.json
php artisan workflows:sync resources/workflows --publish # create / draft / publish; no-op when unchanged
php artisan workflows:sync --dry # report what would change, write nothing$report = Workflows::definitions()->sync(resource_path('workflows'), owner: $team, publish: true, dryRun: false);
$report->definitions; // list<SyncedDefinition{file, key, status, draftOverwritten, version, message, report}>
$report->untracked; // keys of the owner's definitions no file describes
$report->failed(); // any invalid or failed file- Each *.json file (a directory, or one file; default workflows.sync.path) is a roundly.workflows.template/v1 payload: definition.{key, name, subject_type, description} and graph.
- A missing definition is created (created). A graph whose canonical checksum differs from the current version and the draft is saved as the draft (drafted); with publish it is then published if valid (published), else reported invalid with its report and nothing is published for that file. An unchanged file does nothing (unchanged): running a sync twice creates no second version.
- Node ids are kept as written — unlike a template import, which remaps them — so a file syncs the same way every time and versions diff node by node. Owner-specific values are kept as well.
- A draft edited elsewhere (in a builder) that the file replaces is reported (draftOverwritten).
- Nothing is retired or deleted: the owner’s definitions without a file are listed as untracked.
- Owner: app-wide by default; --owner-type=<morph alias> --owner-id= on the commands. Writes go through the definition actions, so events fire.
Diff
$diff = Workflows::versions()->diff($v1, $v2); // VersionDiff
$diff->identical;
$diff->nodesAdded; // ids
$diff->nodesRemoved;
$diff->nodesChanged; // list<NodeChange{node, typeChanged, configChanges, retryChanged, escalationsChanged}>
$diff->nodesMoved; // ui-only changes
$diff->edgesAdded;
$diff->edgesRemoved;
$diff->edgesRelabelled; // list<EdgeChange{from, to, before, after}>
$diff->triggerChanges;
$diff->settingsChanges;configChanges are JSON pointers of the leaves that differ. Map order never counts, list order does. Versions of different definitions → InvalidDiffException.
Templates
$payload = Workflows::versions()->export($version); // or $handle->export()
$result = Workflows::versions()->import($payload, $otherTeam, key: null); // ImportResult
$result->definition; // a new draft definition (key suffixed -2, -3 … when taken)
$result->idMap; // old node id => new node id
$result->flags; // list<ReviewFlag{node, path, reason}> — cleared owner-specific values
Workflows::definition($result->definition->key, $otherTeam)->resolveFlag('n_k3x9q2m7ab', '/recipients');The payload (roundly.workflows.template/v1) carries the definition’s key, name, subject type key and description, the graph, exported_at and the source checksum — no owner, no ids, no class names. Owner-specific values are kept (a same-owner re-import is lossless) and listed in review. Import:
- validates the payload and the subject type;
- remaps every node id to a fresh n_ + 10 base32 characters, rewriting edges, steps.<id> refs and condition fields, start_workflow inputs, notification data and every config string whose schema says format: node-id;
- clears each owner-specific value (user parties, opening_hours: calendars in durations and settings, start_workflow targets missing for the new owner, webhook endpoints, paths a type declares) and records a ReviewFlag{node, path, reason} on the draft;
- creates a new draft definition (key suffixed -2, -3 … when taken).
Publish is blocked by unresolved_import_reference until each flagged path is set (editing it clears the flag) or dismissed with resolveFlag(). A team’s users, calendars and definitions never cross into another team.
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.