---
title: "Definitions and versions — Workflows for Laravel | Roundly"
description: "Create, edit, publish, retire and restore definitions, keep workflows as files with workflows:sync, diff versions and copy templates between owners."
url: https://roundly-consulting.com/open-source/docs/workflows-for-laravel/definitions-and-versions
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)

# Definitions and versions

## Definitions

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

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

```bash
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
```

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

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

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