Skip to content

Architecture

The kernel

The orchestra module is a self-contained engine with no dependencies beyond Drupal core. It provides:

  • Entities: Workflow (config, shared or tenant-scoped) and the runtime trio ProcessInstance, Token and Variable (content entities, each carrying a tenant).
  • Plugin types: the engine's extension points, each with its own attribute and manager: TaskType (what a node does), FlowCondition (is a flow live?), Split (which live flows a node takes), Join (when a node fires on its incoming branches), and Gateway (a routing node expressed as a (join, split) preset). Routing (exclusive, parallel, inclusive) is composed from conditions, a split and a join rather than hard-coded.
  • The engine: WorkflowEngine (orchestra.engine) with start(), advance(), signal(), spawn() and cancel(). It is the only stateful actor.
  • The queue worker: orchestra_advance advances queued tokens on cron.

Submodules

Everything optional lives in a submodule, so the kernel stays small and you pay only for what you enable. These ship today:

Module Responsibility
orchestra Engine kernel: entities, plugin types, engine service, queue worker, task timeouts. See Timers.
orchestra_inbox Human tasks: assign, claim and complete parked work from an inbox. See Human tasks.
orchestra_interaction External-party interactive waits: a public, capability-token-gated dispatcher that lets a non-logged-in party act on a parked step. See External interaction.
orchestra_interaction_webform Collect a Webform submission during an interactive wait and resume on submit. See External interaction.
orchestra_content Bind a process to a content entity and edit it as the work of a step. See Content.
orchestra_content_moderation Drive a content entity's moderation state from a process (a state-transition task).
orchestra_content_eca ECA glue: start a process for an entity, and expose a process's attached entity to ECA.
orchestra_action Run a Drupal Action plugin as automated work in a process. See Actions.
orchestra_ui Browser UI to start, observe and manage instances.
orchestra_modeler Author workflows visually through the Modeler API (BPMN.io).
orchestra_cm Author workflows in accessible Drupal forms, without a diagram canvas.
orchestra_bpmn_io Adapt BPMN.io to Orchestra: preserve the diagram layout across modeler switches and restrict the editor to shapes Orchestra can model.
orchestra_eca ECA integration: start processes from events, emit events from tasks. See Integrations.
orchestra_api The OrchestraClientInterface contract and its in-process LocalOrchestraClient. See Distributed execution.
orchestra_server_api OAuth-gated HTTP API exposing the client contract to remote consumers.
orchestra_client RemoteOrchestraClient: binds the contract to a remote server over HTTP.
orchestra_views Expose processes, tokens, variables and tasks to Views, with readable labels, a tenant filter and ready-made dashboards. See Views.
orchestra_vbo Bulk actions on processes and tokens (cancel, delete, signal) from a dashboard, via Views Bulk Operations.
orchestra_vbo_inbox Bulk actions on the task inbox (claim, complete, reassign) via Views Bulk Operations.
orchestra_audit_trail Record process transitions into the Audit Trail chain: a durable, tamper-evident log. See Audit.
orchestra_mail Email a task's audience when it is assigned and when it is reminded; notification is opted in per assignment. See Notifications.
orchestra_domain Resolve the active tenant from the current domain, binding each domain to a tenant. See Multi-tenancy.
orchestra_examples Ready-to-run example workflows. See Integrations.

Extending Orchestra

Add a node behavior by implementing a TaskType plugin:

#[TaskType(
  id: 'my_task',
  label: new TranslatableMarkup('My task'),
)]
final class MyTask extends TaskTypeBase {

  public function execute(TokenInterface $token, ProcessInstanceInterface $instance): TaskDisposition {
    // Do work, then advance...
    return TaskDisposition::Advance;
    // ...or park to wait for an external signal.
    // return TaskDisposition::Park;
  }

}

A plugin that returns TaskDisposition::Park is resumed by calling WorkflowEngine::signal() on its token, which is exactly how the orchestra_inbox submodule turns a parked wait into a human task, and how the core timeout sweep resumes a task whose deadline has passed (see Timers).

Routing is extended the same way. Implement a FlowCondition (e.g. one that checks the weather or a user's role), a Split (e.g. weighted or random), or a Join (e.g. a threshold or timeout): each is a plugin with its attribute and manager, dropped in without touching the engine.

Resuming a parked task from anywhere

Code that holds the engine service resumes a task directly with WorkflowEngine::resumeWithResult($token, $result), recording the result on the task's result variable (if it declares one, so outgoing flows can route on it) and signals the token.

A node declares its result variable in its node settings: a Result variable name and a Result scope (instance-wide or local to the branch). The plain wait primitive exposes this pair too, not just the higher task types, so a bare wait resumed by a signal or a timeout action can still route its result. The two fields are defined once in ResultVariableConfigTrait and reused across every node type that produces a result. With no result variable a resume just continues with no routing signal (a timeout, for instance, has nowhere to write its result), so give a wait a result variable whenever its outgoing flows need to tell a timeout from a normal resume.

When the resumer does not hold the engine (and to keep it integration-neutral), dispatch the ResumeTokenEvent instead:

$event = new \Drupal\orchestra\Event\ResumeTokenEvent($token_id, $result);
$dispatcher->dispatch($event);
// $event->resumed is TRUE if a parked token was found and resumed.

A core subscriber catches it and resumes the task through the engine. Callers depend only on the event dispatcher, so an ECA action, an HTTP controller, a CLI command or a queue worker all resume a task the same way; an unknown or non-parked token is a harmless no-op.

Cancelling tokens

WorkflowEngine::cancel($token) ends part of a process before it completes on its own. The token and every still-live token descending from it move to the terminal CANCELLED status, distinct from CONSUMED, which marks a token that did its job and moved on, so "killed early" stays visible in the instance and the UI. Cancelling cascades down the lineage (cancelling a branch root kills its whole subtree) and then completes the instance if nothing else is live; an already-terminal token is a no-op.

This is the shared primitive behind ending work early: a superseded branch, a task's pending timers once it is answered, the late branches of a discriminator join.

Reacting to a timeout

The notify timeout action announces a timeout without resuming the task, by dispatching a channel-neutral TaskTimedOutEvent (token, instance, node and tenant IDs, plus a tag and message from the node):

// In a subscriber:
public function onTimeout(\Drupal\orchestra\Event\TaskTimedOutEvent $event): void {
  // $event->tag, $event->message, $event->instanceId, …
}

The engine never sends a notification itself; it only announces. A plain EventSubscriber reacts directly; the orchestra_eca submodule bridges the event to an ECA custom event so a site builder wires email/Slack/log with no code. Because the task stays parked, the sweep re-arms it and the event recurs until the task is handled (see Timers).