Skip to content

Running transitions

Transitions run in the panel, in code and through the API – always with the same checks.

When a transition runs, in one transaction:

  1. The workflow is active in the tenant (inactive).
  2. The record’s workflow row is locked, so two people cannot move it at the same time.
  3. The transition exists in the record’s version and starts in its state (unknown_transition, wrong_state). Optional transitions switched off by the tenant count as unknown.
  4. The current user or API client matches an actor – or may override (not_allowed).
  5. Four eyes (four_eyes) and guards (guard).
  6. The reason (required, at most 2000 characters) and the form (validation).
  7. The state changes, the history and the audit entry are written.
  8. The tasks of the old state are closed, those of the new state created.
  9. The effects run.

If any step fails, nothing is stored.

The detail page of a record shows, per workflow, the state, the transitions the user may run now, the tasks of the record and the history (see Lists and detail page).

  • Transitions with a reason or a form open a dialog, confirm() asks first.
  • Only transitions the engine offers are shown; everything is checked again when one runs.
  • After the transition a message names the new state.
$engine = service('workflows');
$workflow = $resource->workflow('order-approval');
$engine->transitions($resource, $workflow, $record); // what the current user may run
$engine->apply($resource, $workflow, $record, 'approve'); // returns the new state, throws WorkflowException
$engine->apply($resource, $workflow, $record, 'order', ['order_number' => 'PO-1'], 'Urgent');
$engine->runAsSystem($resource, $workflow, $record, 'reject', 'Imported as rejected', 'import');
$engine->state($resource, $workflow, $record); // null when the workflow is not active
$engine->history($resource, $workflow, $record); // transitions, oldest first
$engine->isLocked($resource, $record);
Method Purpose
transitions() the transitions the current actor may run: name, label, target state, reason (none, optional, required), form, confirm, override
apply($resource, $workflow, $record, $name, $input = [], $reason = null) runs a transition as the current user; $input holds the form values in input format
runAsSystem($resource, $workflow, $record, $name, $reason, $label) runs as the system: no actors and no four eyes; $label names the system actor in the history
state(), history(), isLocked() read the state
isActive($workflow) whether the workflow is active in the current tenant

Run code in the tenant of the record (tenantContext()->runAs()) in commands and jobs.

Anvil\Workflow\WorkflowException::$reason names the refusal:

Reason Meaning
inactive the workflow is not active in the tenant
unknown_transition no such transition (or switched off)
wrong_state the record is not in a starting state of the transition
not_allowed not an actor and no override permission
four_eyes the actor ran an excluded earlier transition or created the record
guard a guard failed
validation reason or form invalid; $errors holds the messages
conflict the record changed at the same time
configuration the definition or configuration is broken

With 'transitions' in Resource::$api (and the view permission of the resource):

Method and path Purpose
GET /api/v1/orders/{uuid}/transitions state and transitions the client may run, per workflow
POST /api/v1/orders/{uuid}/transitions/{name} runs a transition; body: reason and the form fields; answers with the new state and the record

The state column is part of the representation (read only); for workflows with durations also <column>_due_at, <column>_forecast_at and <column>_risk. API clients match permission actors, and the creator actor for records they created; four eyes compares clients like users. Refusals: 403 (actor, four eyes, guard), 409 (state, inactive), 422 (reason, form). See API for machine clients.

Records created in the panel or through the API start in the initial state of every active workflow, with their creator. Records created elsewhere (e.g. an import) start on their first transition: a known state in the state column is kept, otherwise the initial state applies; their creator is unknown.