Variables¶
Most process variables come from outside the run: the payload the starter
passed, a form submission, the payload a task wrote. A variable provider
produces one from the run itself, and the variables node is where the
model asks for it. A workflow can also
declare what its own runs start with,
without a provider or a starter that knows about it.
The point is when it happens. A value computed at start is a value that was true at start; by the time a branch reads it, the subject may have moved on. A variables node placed just before the branch computes it there, so the gateway routes on the state as it stands at that step.
flowchart LR
s([Start]) --> b["Basket edited<br/>type: wait"]
b --> v["Compute<br/>type: variables"]
v --> g{deposit due?}
g -->|yes| d["Take deposit<br/>type: action"]
g -->|no| e([Done])
d --> e
The variables node¶
A node of type variables runs the providers it selects, writes what they
return and advances. It never parks and has no inbox.
n_prepare:
type: variables
config:
providers: '["shop_arrival","shop_deposit"]'
completion_scope: instance
providers: the provider plugin IDs this step runs, as a JSON list, in the order they run. In the Complete modeler it is a drag-to-reorder table of the providers this step runs, each row naming the variables that provider declares and carrying a Remove button, with a select and an Add button below it for the providers not yet on the step. Both act on the spot, without saving the workflow. The table lists the step's own providers only, so it stays the same size however many the site installs.completion_scope:instance(the default, shared by the whole process) ortoken(local to this branch and its descendants), so parallel branches can each compute their own answer under the same variable name.
A BPMN modeler edits the same node through its properties panel, which renders one field per setting and has no rebuild for a table to drag or a button to press. There the list appears as the flat JSON value it is stored as, editable and saved as typed, with the drag table and the add controls left to the workflow editor that can actually run them.
Which providers run is part of the model. A module that ships a provider contributes nothing until a node selects it: installing it is not enough. That is deliberate. The graph already has to contain the branch that reads the variable, so the model was always coupled to the module; selecting the provider makes the other half of that coupling explicit, and the workflow config records a dependency on the module instead of leaving the result to whatever happens to be installed.
Writing a provider¶
Drop a class in your module's src/Plugin/VariableProvider/ directory:
#[VariableProvider(
id: 'shop_arrival',
label: new TranslatableMarkup('Arrival'),
description: new TranslatableMarkup('The earliest slot start of the order.'),
provides: ['arrival'],
)]
final class ArrivalProvider extends VariableProviderBase {
public function getVariables(TokenInterface $token, ProcessInstanceInterface $instance): array {
$order = $this->orderFor($instance);
return $order === NULL ? [] : ['arrival' => $order->earliestStart()];
}
}
A provider reads and returns; the node writes. Keep it side-effect free and cheap, since it runs inline in the step: anything slow, and anything that changes state, belongs in a task of its own.
Returning an empty array is how a provider says "this run is not my case". The variable then stays absent, rather than being written as an empty value every flow condition downstream would have to allow for.
provides is documentation for the editor: it tells a modeler what selecting
this provider contributes. Nothing rejects a provider that returns a name it
did not declare.
Order and collisions¶
Providers normally own disjoint variables, and then order does not matter. When two do write the same name, the last one wins, and the order is the one the node lists them in: the editor is a drag-to-reorder table, and the stored JSON list keeps that order. So the winner is readable in the model, rather than being a property of the plugins or an accident of the order they were discovered in.
When a provider fails¶
Every provider is asked before anything is written. A provider that throws therefore leaves the instance exactly as it was, rather than half-updated with the values of whichever providers ran first: the step fails whole and dead-letters to an incident, where it can be retried once the cause is fixed. Half a set of branch conditions is worse than none.
A selected provider whose plugin no longer exists (its module was uninstalled) is logged and skipped. The rest of the selection still applies, so an uninstall never strands a running process.
Variables the definition declares¶
A run starts with exactly the variables its caller passes to the engine's
start(). A workflow can add to that: a definition may declare the
instance variables its runs begin with, and the engine seeds them when the run
starts, before its first step.
# orchestra.orchestra_workflow.shop_order.yml
variables:
- name: channel
value: web
- name: deposit_due
value: 1
Edit them from a workflow's Variables operation on the model list, a name and a value per row. The form opens with one empty row and an Add another variable button for the next, and clearing a name stops declaring it.
This is for what the workflow itself knows, not what a particular run knows. It means a workflow that needs an input no longer depends on whichever code starts it: a run started from the workflow list, or by an ECA action, or imported as configuration into a site that has no code of its own, still begins with the values it needs.
A caller wins. Where the caller passes a name the definition also declares,
the caller's value is used, because the caller knows the facts of the
particular run (the entity being acted on, a correlation id) while the
definition knows only its own defaults. This holds for a value passed
explicitly as NULL: the caller said so, and is not falling back. A
subprocess child is a caller like any other, so the names its
parent maps in win, and the child's own declarations fill in the rest.
Declared values are seeded at instance scope, so every branch sees them. There is no declared token-local variable: a definition cannot know which branch it would mean.
A few rules worth knowing:
- A name starts with a letter, then letters, numbers and underscores. That
keeps it out of the engine's reserved
__namespace (where a node's timeout anchor lives, among others), which nothing outside the engine may write, and keeps the name readable by a routing condition, which reads a dot as a path into a structured value. - The Variables form writes each value as text, which is what a routing condition compares against. A declaration that has to hold a number, a boolean or a list can carry it in imported configuration, since a variable stores whatever JSON can encode; the form shows such a value and leaves it alone rather than flattening it.
- The declarations are part of the workflow's executable shape, so editing them cuts a new version. Runs already going keep the values the version they pinned declared.
On an instance afterwards, a declared value and one written mid-run look identical: both are just instance-wide variables, and the variable listing does not say where a value came from. The definition is where the two are told apart, so a variable a reader is meant to recognize as a starting value is worth naming as one.
Choosing between this and the alternatives¶
- Declaring initial variables on the workflow states what a run begins with. Use it for a default, a mode or any input the definition itself knows, rather than making every caller pass it.
- A variables node computes values with code you own, selected per step. Use it for anything a branch routes on.
- An action task does something. Use it when the step has an effect; a context-aware action can also write variables, but a step that only computes is clearer as a variables node.
- ECA covers the same ground without code, driven by conditions and actions configured in the ECA UI. Reach for a provider when the computation is genuinely domain logic that belongs in a class.