Skip to content

Concepts

The token model

Orchestra borrows its execution model from Petri nets and BPMN. A process is a graph; a token is a marker that lives on one node of that graph and represents a point of control currently in progress. The engine does one thing, repeatedly: take a token, execute the node it sits on, and decide what happens to the token next.

Concept Stored as Role
Tenant Config entity An isolated realm; runtime rows partition per tenant.
Workflow Config entity The template: nodes, flows and a start node.
Process instance Content entity One running execution of a workflow.
Token Content entity A marker on a node; the unit of execution.
Variable Content entity A named value carried by an instance.

The lifecycle

Starting a workflow creates an instance and places one token on the start node. From there the engine advances tokens:

  • Advance: the node's work is done. The token is consumed and a successor is produced on each outgoing flow the node's routing keeps (see Routing below). Several successors run as concurrent branches.
  • Park: the node is waiting (a human task, a timer). The token parks on the node and the engine moves on. A parked token resumes only when it is signaled, which moves it past the node along the outgoing flows.

An instance completes once no token of it is still active or parked.

flowchart LR
  start([Start]) --> work[Script] --> hold{{Wait}} --> finish([End])

In the flow above, a token advances through Start and Script, then parks on Wait. When the wait is over the token is signaled, advances to End, and the instance completes.

Advancement

A process advances one node at a time, and how those advances are scheduled is the execution mode. Synchronously, the default, the engine advances the process's own tokens inline once the operation that woke it commits, so starting a process runs it straight to its first wait. Queued, each active token goes on the orchestra_advance queue and a queue worker advances it on cron, so a spike of work is spread across cron runs. Either way a parked token waits until it is signaled, and parallel branches advance independently of one another.

Task types

What a node does is a TaskType plugin. The kernel ships seven primitives:

  • start: the entry point; advances immediately.
  • end: a branch exit; advances and, with no successors, lets the instance complete.
  • passthrough: an automated step; advances immediately (a no-op pass-through in the kernel, a hook for real automated work).
  • wait: parks the token until signaled; the domain-agnostic primitive that the human-task submodule and other integrations build on.
  • variables: runs the variable providers it selects, writes what they return and advances, so a gateway routes on state computed at that step rather than at start.
  • user_operation: parks as an assignment-gated step a person acts on from their pending-actions list rather than the inbox, the pull-mode sibling of the inbox user task.
  • subprocess: runs another process as a child and parks until it completes, resuming with the child's mapped output. A child that fails or is canceled can be routed on or retried. See Subprocesses.

Timeouts

A parked task waits for a signal that may never come, so the engine bounds the wait. A parking node opts in with a node-level timeout map naming two plugins and giving each its own settings: a DeadlineProvider (deadline, deadline_settings) that says when the wait runs out, and a TimeoutAction (action, settings) that says what to do once it has.

Orchestra bundles two providers. relative waits for a duration, measured from each park, from the start of the run, or from the first arrival at the step; it falls back to the site-wide default_timeout, which is what makes that setting a safety net for every parked task. absolute waits until a date, optionally shifted by a signed offset. A module ships its own when the deadline belongs to something the run does not own, so it is derived from that subject as the task parks rather than read from a value some earlier step had to write.

On expiry a cron sweep runs the action, by default resume, which records a timeout outcome the outgoing flows route on. The action is pluggable too, so a timeout can instead notify, release a claim, spawn a parallel branch, or cancel; and a node can stage several into a timers escalation. See Timers.

Routing: conditions, splits and joins

Branching is not a special node type. It falls out of three small, pluggable pieces that apply to every node.

Flow conditions

A flow may carry a condition (a FlowCondition plugin). When a node finishes, the engine keeps only the outgoing flows whose condition holds (a flow with no condition always holds): the live flows. The kernel ships:

  • comparison: a process variable against a value (==, !=, >, >=, <, <=, empty, not_empty).
  • all / any: composites holding child conditions, so they nest into a boolean tree of any depth.
  • count: how many entries of a list variable equal a value, compared against a threshold (>=, <, ...), the basis of a quorum.

A condition's variable may be a dotted path into a structured value: decision.outcome reads the outcome key of the array held in decision. So a structured completion payload (e.g. a human decision recorded as {outcome, comment}) routes on decision.outcome while decision.comment rides along.

A structured payload carries its outcome under an outcome element, so decision and decision.outcome are equivalent: comparing the plain name matches the outcome, not the whole payload, and an .outcome path on a plain value returns that value. A branch therefore routes the same whether you write decision or decision.outcome, and whether the task recorded a bare outcome or an {outcome, comment} map, so you never have to know which shape a completion produced.

A custom condition (the weather, a role, an API call) is just another plugin.

Split: which live flows to take

A node's split (a Split plugin) chooses, among the live flows, which the token follows: all (the default) takes every one, first takes only the first. Conditions and split compose: the conditions say which branches are eligible, the split says how many. That one rule expresses the classic shapes: mutually exclusive conditions are an exclusive choice, no conditions a parallel fork, overlapping ones an inclusive split. And it works off any node, so a task can branch on its own.

Join: when to fire

A node's join (a Join plugin) is the incoming side: when a token arrives, it decides whether to fire now or wait for more. The decision runs atomically under a per-node lock, so two branches completing at the same instant cannot both believe they are last (firing twice) or both believe they are not (leaving it stuck). The kernel ships:

  • immediate (the default): fire on each arrival (a merge).
  • wait_all: the AND-join: fire once a token has arrived from every incoming branch.
  • threshold: fire as soon as N of the M branches arrive, then cancel the rest (an early-firing discriminator).
  • quorum: fire as soon as a vote is settled, either enough branches approve or approval becomes unreachable, then cancel the rest.
  • timeout: fire when every branch arrives or a deadline passes, cancelling the branches still running.
  • matching: the OR-join: fire once every incoming flow whose condition holds has arrived. Because the same conditions drove the upstream split, the join waits for exactly the branches that were activated, without tracking tokens across the graph.

Variable scope and the join merge

A variable is instance-wide by default, shared across the whole process. A variable may instead be token-local: set on one token, it is visible only to that token and the tokens descended from it (each token records its parent, so a value set on a branch flows down to that branch's successors). Token-local is the right scope for a per-branch decision, so parallel branches running the same step do not overwrite each other.

When a join fires, its optional merge policy collects one variable from each joined branch into a list. With the count condition this turns parallel branch decisions into a quorum, with no special node type:

flowchart LR
  start([Start]) --> r1[Review 1]
  start --> r2[Review 2]
  start --> r3[Review 3]
  r1 --> tally{{"wait_all<br/>merge vote → votes"}}
  r2 --> tally
  r3 --> tally
  tally -->|approved ≥ 2| approve[Approved]
  tally -->|else| reject[Rejected]

Each reviewer writes its vote token-locally; the join waits for all three and merges their votes into a votes list; a count condition routes on how many are approved. After the join, the continuing token is placed under the branches' common ancestor, so per-branch locals do not leak past the join while variables set before the split still resolve. This is the example_quorum workflow in orchestra_examples.

Correlation: finding a process by its business key

An external event often has to reach the process waiting for it: a payment return, an inbound webhook, a message keyed by a business reference. An instance carries an optional first-class correlation key (a business key, like Camunda's businessKey or Zeebe's correlation key), set at start():

$engine->start('order_fulfillment', $vars, NULL, (string) $order->id());
// ... later, when the payment provider calls back:
$instances = $engine->findInstancesByCorrelationKey((string) $order_id);

The key is an indexed scalar column on the instance, scoped to the acting tenant, so the lookup is a plain index-served equality, portable across databases. It need not be unique, so several instances may share one.

The key is a string, even when your reference is really an integer: resolve with it (a varchar = string comparison is portable), but do not join an integer column against it (integer = varchar has no portable cast and skips the index). For a persistent link or a Views relationship, store the instance id (the integer primary key) on your entity and join on that: the correlation key is the handle that resolves the instance from an external event, the instance id is the foreign key you join on.

Initiator: who started a process

An instance records its initiator: the user who started it (the Camunda startUserId / camunda:initiator, the WS-HumanTask task initiator), as opposed to a task's actual owner, which orchestra models as the assignee. It is passed at start(), empty for a system, API or cron start; a subprocess inherits its parent's:

$engine->start('request_validation', $vars, NULL, NULL, (int) $account->id());
// ... later:
$uid = $instance->getInitiatorId();

Like the assignee, the initiator is stored as a bare uid (not a user entity reference), so the runtime instance keeps no hard dependency on the user module. It is indexed, so "the requests a user initiated" is a paged, index-served query rather than a table scan.

The instances list (orchestra_ui) shows an Initiator column and filters by initiator through a username autocomplete, and the Views integration (orchestra_views) exposes the initiator as a linked-username field plus two filters: by user (the core username autocomplete) and an "Initiated by the current user" convenience for a "my instances" list.

Building on that, a requester sees their own requests without operator access: orchestra_ui ships a themed My requests page at /orchestra/my-instances, and orchestra_views a matching My workflow instances View at /orchestra/my-requests. Both list only the instances the current user initiated, scoped to the acting tenant, with each request's lifecycle status and the author-defined status below. They are gated by a dedicated View own Orchestra process instances permission, so a requester needs no administrative rights. The page is linked from the Content admin menu (My Orchestra requests), beside Orchestra tasks and My Orchestra actions, so a user finds the requests they started next to the work awaiting them. (The parked step is still available for operator dashboards as the orchestra_views Current step field.)

Opening a request, a card on the page or the View's linked Reference column, leads to a read-only page at /orchestra/instance/{instance}, with its workflow, reference and current status in the header, a progress timeline of the statuses it has passed through, and the data the requester submitted. It shows none of the operator trace, no tokens, states, incidents or variable dumps, only what the person who filed the request would recognize.

That page is the run's, not one audience's, so there is one address per run and a link to it works for whoever is entitled to open it. Entitled means one of the run's own people, which the read access check answers:

  • its initiator, as ownership, always;
  • an operator, as far as the run's read access admits them. Before this, the run behind a task was reachable only through the admin trace, so an operator without administrative rights could not see it at all.

The page is a template, orchestra-instance.html.twig: a theme overrides it by placing a file of that name in its own templates directory, and receives the run's parts (its meta columns, its status, its milestones, its source view) ready to print, each of them one of the shared components in orchestra_presentation. So an override reorders or drops parts without writing markup, a theme that wants a column or a status tag to read differently everywhere overrides that component's own template, and restyling alone needs only CSS.

Read access: who else may read a run

How much of a run's operator audience may read it depends on what the run is about, so it is configurable, with three values (narrowest first):

Scope Who reads the run, besides its initiator
Only the person who filed it Nobody. An operator keeps their task and the page it is acted on, and gets no page for the run behind it.
Its workers (the default) Whoever holds one of its tasks and whoever completed one, plus a holder of reassign orchestra tasks for a task somebody else holds. Claiming the work is what makes it yours.
Its whole audience Everyone a task is offered to, the inbox's own visibility rule. A task pooled to a role opens the run to that role, which is why this is not the default.

Set it site-wide under Runs are read by on the Orchestra settings page, and override it per tenant (its Reading operation on the Tenants list) or per workflow (its Reading tab). The workflow's choice wins, then the tenant's, then the site's, the same precedence and the same shape as retention overrides, so there is one rule to learn for both.

Whatever the scope, the answer outlives the work: a completion releases the hold but records the completer, so an operator who processed a request can still look it up afterwards rather than losing it the moment they finish. WorkItemManagerInterface::isAccountParticipant() is the one place that answers this, and under the widest scope it reuses getAccountVisibilityCondition(), the rule the inbox and the pending-actions list already filter on, rather than restating it.

The route needs no permission, only a logged-in user, because being one of the run's people is the stronger requirement; and the reader decides only two things on the page: an operator is told whose run it is (the beneficiary) and is sent back to the list they came from, while the requester is sent back to My requests. Nobody reaches another run by editing the id, and unlike the admin instance routes a full administrator does not bypass this either: the tenant-wide view is the trace. The timeline is derived, not stored: it walks the instance's token history and maps each status-bearing node a token visited through the status vocabulary, oldest first, consecutive duplicates collapsed, so it needs no new storage and no optional module. The submitted data is rendered through a neutral orchestra.instance_source seam that mirrors the status collector: a module binding an instance to a source tags a provider that recognizes its own instances and renders their data, so orchestra_interaction_webform shows the bound webform submission and a booking module would show its booking, the detail surface staying agnostic to the source.

A requester-facing status

A workflow narrates where a run stands for the person who started it through the Status node feature: a node references a status from a shared, tenant-scoped vocabulary, and the requester can filter their requests by it. A status is a vocabulary term (the orchestra_status config entity) carrying its own label ("Under review", "Approved") and a palette class, defined once and referenced by machine name, so several nodes share one term and its wording and styling never drift. Statuses are managed at Configuration > Workflow > Orchestra > Statuses, tenant-scoped exactly like workflows: a status with no tenant is shared across every tenant, one scoped to a tenant is offered only there, so a tenant admin can maintain their own vocabulary (resolved through the orchestra.status_repository service).

Status is independent of any interaction, so it sits on any node, an operator task included, without touching that step's form or message. An instance's current status is the status the node it is parked on references, or the single end node it finished at once completed, so a final outcome is modeled as the status on distinct end nodes ("Approved" / "Rejected"); a step referencing none contributes nothing, and the list falls back to the lifecycle status, never a node label. The engine stores the current status id on the instance as it moves, so the requester list filters on it in the database and resolves the displayed term without per-instance lookups, through the base orchestra.instance_status collector a surface reads without a dependency on any interaction. Styling is class-driven: the term's palette class plus a status-id hook (orchestra-status--<id>) ride onto the tag, and the cards library in orchestra_presentation ships a small default palette (is-info, is-success, is-warning, is-danger, is-neutral) a theme can override or extend. That library holds the whole card component (the card, its labeled columns, the status tag and the lifecycle marker), so every surface listing runs or tasks renders the same thing and a theme restyles it once.

Loops and re-entry

A loop is not a special construct; it is just a flow pointing back to an earlier node (a "send back for rework" arc, say). Two things make loops work without bookkeeping:

  • Re-entering a parking node regenerates its work. When a token returns to a user (or any parking node), it parks again and a fresh task is created: the reviewer gets a new task each pass.
  • A join re-entered by a loop re-arms on its own. A join's only state is the set of tokens currently parked waiting at its node, and firing consumes them; so the next pass through the join starts from a clean slate, with no stale arrival to mis-fire it.

One caveat: a wait_all (AND) join inside a loop fires only when every incoming branch arrives, so a loop that re-feeds just one of its branches will wait forever; arrange the loop to re-enter the fork, not a single branch. (The early-firing threshold, quorum and timeout joins, which discard late "straggler" branches, scope that teardown per iteration with fork cohorts, so a loop re-entering the fork starts a fresh cohort. See Joins and splits.)

Task assignment

A user task is pooled by default: anyone with the permission sees it in the inbox and may claim it. To target a task, give it an assignment. The plugin type is Audience (Plugin/Audience, #[Audience]): an audience resolves who a node reaches. An audience that can also staff a task implements AssignmentInterface (extending AudienceInterface) and answers two more questions in opaque string tokens: who a task is for (its candidates, minted when the task is created) and which audiences a given viewer belongs to (their viewerTokens, computed at the inbox). The inbox shows a task when those two sets intersect; an empty candidate set means the task stays pooled. A viewer standing in for somebody carries that person's tokens as well as their own, so a delegation widens what matches here without touching a single task. The resolved candidates live on a multi-value field on the task, so assignment is matched as a plain query, not recomputed per request.

Four staffing audiences ship. users (tokens like user:5) and roles (role:editor) name their audience in config. users_variable and roles_variable read it at task-creation time from a process variable ("the user named in approver"), so who handles a task is decided at runtime by an upstream step rather than baked into the workflow: the variable holds a user ID or username for the first and a role machine name for the second, or a list of either, and is read against the parked token's lineage (a branch-local value is seen). Each mints the tokens of its static counterpart, user: or role:, so the audience a variable names finds the task in the same inbox query. A node either carries the flat modeler fields assignee_users / assignee_roles, or a structured assignments list whose entries union, so a task can be offered to a role pool and a named user at once. Because tokens are opaque strings, a new audience (a group, an org unit, an expression) is just a new plugin minting its own token namespace, with no schema or storage change. The ballots of example_quorum use the structured form, the third of them a union of a role pool with a named user.

Each assignment also carries a notify flag and resolves its audience to concrete accounts (getRecipients(), the notification counterpart of getCandidates()). This is a neutral intent the inbox never acts on itself: the optional orchestra_mail submodule reads it to email the audience, and any other notifier (an ECA model) can read it too. The user-based audiences (users, users_variable) notify by default; a role pool (roles, roles_variable) opts in, so pooling to a broad role never mails its members by surprise.

Gateways

A gateway is a routing node defined as a (join, split) pair, with no task of its own. The named gateways are presets over those two knobs:

Gateway Join Split Behavior
parallel wait_all all Fork every branch; join waits for all.
exclusive immediate first Take the first matching branch; merge.
inclusive matching all Take every matching branch; join waits for those.

An exclusive choice (approve or reject) needs only conditions on the flows:

flowchart LR
  start([Start]) --> g{Approved?}
  g -->|approved == true| approve[Provision]
  g -->|else| reject[Notify]
  approve --> done([End])
  reject --> done

A parallel fork and AND-join run both branches and wait for both:

flowchart LR
  start([Start]) --> fork{{Fork}}
  fork --> a[Branch A]
  fork --> b[Branch B]
  a --> join{{Join}}
  b --> join
  join --> done([End])

Because join and split are independent and pluggable, the same machinery covers more than the named gateways: a task node can itself be a join point, a synchronous quorum is wait_all + a merge + a count condition (no special node), and the early-firing threshold, quorum and timeout joins are each just a Join plugin over the same machinery.