Content-bound tasks¶
The optional Orchestra Content submodule (orchestra_content) binds a
process to a content entity and adds a task whose work is editing that entity.
It closes the gap with Maestro's signature step: a process that is about an
article, and a review step that opens that article's edit form.
How a process attaches to content¶
The engine kernel stays content-agnostic: it knows nothing about entities.
Instead orchestra_content owns a small side entity,
orchestra_attachment, that references the process instance and the entity
(entity_type_id + entity_id). The binding is queryable both ways, and the
kernel and the instance entity are untouched:
| Question | Resolved by |
|---|---|
| Which content is this run about? | AttachmentManager::getProcessInstanceEntity($instance) |
| Which runs are about this content? | AttachmentManager::getEntityProcessInstances($entity) |
Start a process already bound to an entity by binding from inside the start,
which is what ProcessControlInterface::start()'s on_started callable is
for:
$engine->start($workflow_id, $variables, initiator: $uid,
on_started: fn ($instance) => $attachments->attach($instance, $entity));
Attaching after start() returns is the one thing to avoid: the shipped
default execution mode drains the run inline, so the binding would arrive after
the very nodes that read it.
Multiple attachments (keyed)¶
A process is rarely about just one thing: an order has a customer and an invoice, a publication has an author and an editor. So a run can have several entities attached, each under a key (the role it plays). Every call takes an optional key; the empty key is the primary subject and is what the single-argument calls use, so a one-entity workflow needs no key at all.
// Bind the order (primary), then the customer and the invoice under keys.
$attachments->attach($instance, $order);
$attachments->attach($instance, $customer, 'customer');
$attachments->attach($instance, $invoice, 'invoice');
$attachments->getProcessInstanceEntity($instance); // the order (primary)
$attachments->getProcessInstanceEntity($instance, 'customer'); // the customer
$attachments->getProcessInstanceEntities($instance); // all three, keyed by key
$attachments->getEntityProcessInstances($customer, 'customer'); // runs where it is the customer
$attachments->detach($instance, 'customer'); // unbind one key, run unaffected
A process binds one entity per key: attaching again under a key already
bound re-points it at the new entity rather than adding a second row, and a
different key coexists. detach() unbinds one key (the binding only; the run
continues), and deleting the instance clears them all. The key is a short
machine name (a role); a consumer that acts on "the attached entity" (the
entity-edit task, the moderation task, the Content action task, and the ECA
actions) has an Attachment key setting to say which one, defaulting to the
primary subject.
Binding something the run computed¶
The binding a run starts with is made before its first node. For content a run
only learns about while it is running, the invoice it created at step four or
the document it picked for a reviewer, put an Attach content node
(attach_entity) in the graph where the answer becomes known.
It is configured with two process variables, one holding the entity type to bind and one holding that entity's ID. Naming both rather than fixing a type in the node keeps the model usable for whatever content the run turns out to be about, and keeps the workflow from depending on the module behind a type it names. An Attachment key gives the binding a role of its own, leaving the run's primary subject alone.
An Outcome variable makes a binding that cannot be made routable: the
variable is set to success or failure and the token advances, so an
outgoing flow condition can branch to an error path. Left empty, a failed
binding halts the step on an incident instead (or fails the run, on a site whose
on_unrecoverable_failure says so), because advancing would walk the run into a
later step whose attachment is not there, which is the failure binding exists
to prevent.
Binding is security neutral. It records which entity the run is about and nothing more: nothing is read from the content, written to it or saved, so there is no operation to decide about and nothing to permit or refuse. Anyone who can configure a model can bind anything to it, and the ID variable can name any entity of its type.
Access is decided where an attachment is used, because that is where there is an operation: the entity-edit interaction checks the viewer's update access before opening the form, and an automated step (a Content action task, a moderation transition) has no actor to check against and so acts with full privilege, which is what makes the model's own choice of steps the thing that matters.
The entity-edit interaction¶
The entity_form interaction (in orchestra_content) makes editing the
attached entity the work of a step. Attach it to an
interaction task: the node parks the token
and creates a work item like any user task, reusing assignment, outcomes and the
inbox, and opening the task hands the assignee to a single screen showing the
attached entity's edit form with:
- a plain Save: persists the edits but leaves the task open, so the reviewer can edit across several sittings before deciding;
- an optional Comment: recorded with the decision (as
decision.commentalongsidedecision.outcome); - one save-and-decide button per outcome: saves the entity and completes the task with that outcome (which the engine routes on), in one action.
flowchart LR
c["Article created"] --> s([Start])
s --> r["Review<br/>type: interaction_task<br/>interaction: entity_form<br/>outcomes: approved, rejected"]
r -->|approved| p([Publish])
r -->|rejected| d([Send back])
Node config (in the Complete modeler or in YAML):
n_review:
type: interaction_task
config:
outcomes: 'approved,rejected'
payload_variable: decision
assignee_roles: editor
interaction_plugin: entity_form
interaction_settings:
form_operation: edit
attachment_key: ''
outcomes/payload_variable/completion_scope/assignee_*: the same keys a user task uses; the chosen outcome is recorded and routed on by the outgoing flow conditions.interaction_plugin: entity_formwith itsinteraction_settings:form_operation(which entity form mode to open, e.g.edit) andattachment_key(which attached entity to edit; empty is the primary subject).
Opening the task hands the assignee to the content route
(/orchestra/content/task), which renders the attached entity's form under the
assignee's identity. The interaction owns its surface, so there is no external
URL to configure.
The route carries the parked token and nothing else. Which entity to open and
which of its forms are read from the step's own settings, never from the
request: a form operation selects the form handler the entity type builds, so a
request that could name it could open any form that entity type declares,
including its delete form. The screen then re-applies the entity's own update
access, which building a form directly would otherwise bypass. A
form_operation the entity type does not declare answers 404 and is logged,
naming the step.
Same machinery as a user task
Because an interaction task is a parking human task, everything the inbox offers applies: claim, reassign, pooled vs. targeted assignment, and escalation timers. Only the work differs: an entity edit form instead of outcome buttons.
Worked example: edit then approve¶
- An article is created; a content workflow is started with two variables naming the article, and its first step binds it (an Attach content node, above).
- The token reaches the interaction-task review node (its
entity_forminteraction), parks, and a work item appears in the editors' inbox. - An editor opens it: the article's edit form renders, with Save and approve / Save and reject buttons.
- They fix a typo and press Save and approve; the article is saved and the
task completes with
approved;decisionis set toapproved. - The outgoing flow condition routes the token to the publish branch.
Driving the moderation state¶
The companion orchestra_content_moderation submodule (requires core
Content Moderation) adds a moderation_transition task that sets the
attached entity's moderation_state. Put it on the branch that should reach a
state, so the graph expresses the mapping (approved → set published,
rejected → set draft) with no mapping table. Orchestra is the authority on
when the state changes, so it sets the state directly (a state the entity's
moderation workflow defines; an entity that is not moderated is skipped).
Starting a process for an entity¶
The entity_form task edits the entity the process is attached to, so
something has to start the process and bind it (deliberately not a global
save-watcher). Where the binding is made is the model's to decide: give the
workflow an Attach content first step and every caller only has to name the
content in variables, or bind from inside the start and callers arrange it
themselves. Two ways to start one:
- In code:
start()with the variables the attach step reads, or with anon_startedcallable that callsAttachmentManager::attach()when the binding has to be in place before the first node runs. - From ECA: the generic "Orchestra: start process" action
(ECA integration), with the content named in its process variables:
subject_type: [entity:entity_type]andsubject_id: [entity:id]. Wire it to an entity insert/update event (the event's own condition is the de-duplication) and creating the content starts a run the workflow's attach step binds, the "when to start" living in the model, where Maestro puts it. Theorchestra_content_ecasubmodule adds the other direction, a "load the process's attached entity" action, so a model reacting to an Orchestra event can act on the content (e.g. set its state via content_moderation's own ECA action).
Either way the run records who asked for it, which is what puts it in that person's own list of runs and admits them to it under the narrowest read scope. The ECA action records whoever's request fired the model, since they did the thing that started the run; an unattended one, under cron or a queue, records nobody. The code call takes the uid as its last argument and records nobody unless you pass one, because a library cannot tell whether it is running for a person. Neither uses the content's owner: being the subject of a run is not the same as having asked for it, and the initiator is admitted to read the run.
A ready-to-run example¶
orchestra_content_moderation ships an example_content_review workflow
(review the entity → approve publishes it, reject sends it back to draft), and
orchestra_content_eca ships an example ECA model that starts that review when
an article is created, naming the article in those two variables. Enable the
modules to get both.
Status and follow-ups¶
Shipped: the attachment (one per key, several per run), the entity_form
interaction and edit screen, the moderation_transition task
(orchestra_content_moderation), the attach_entity task, and the ECA
entity-exposure action with example configs (orchestra_content_eca).
For a generic automated step that runs any Drupal Action plugin (of which
moderation_transition is a special case), see Action tasks
(orchestra_action).