Skip to content

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.comment alongside decision.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_form with its interaction_settings: form_operation (which entity form mode to open, e.g. edit) and attachment_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

  1. 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).
  2. The token reaches the interaction-task review node (its entity_form interaction), parks, and a work item appears in the editors' inbox.
  3. An editor opens it: the article's edit form renders, with Save and approve / Save and reject buttons.
  4. They fix a typo and press Save and approve; the article is saved and the task completes with approved; decision is set to approved.
  5. 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 an on_started callable that calls AttachmentManager::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] and subject_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. The orchestra_content_eca submodule 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).