Skip to content

Architecture

The kernel

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

  • Entities: two config entities, Workflow (a model, shared or tenant-scoped) and Tenant; plus the runtime content entities ProcessInstance, Token, Variable, WorkflowVersion (an immutable snapshot a running instance is pinned to) and Incident (a recorded execution failure), 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), Gateway (a routing node expressed as a (join, split) preset), NodeFeature (opt-in behavior added to a node, such as external interaction, notify-on-arrival or a payload variable), VariableProvider (a value a variables node computes from the run itself), DeadlineProvider (when a parked task's wait runs out), TimeoutAction (what a task's timer does when it fires) and Audience (who a task or a notification reaches). Routing (exclusive, parallel, inclusive) is composed from conditions, a split and a join rather than hard-coded.
  • The engine: WorkflowEngine (orchestra.engine), the only stateful actor: start(), advance(), signal(), resumeWithPayload(), spawn() and cancel(), plus the incident operations (retryIncident(), resumeIncident(), skipIncident(), cancelIncident(), failFromIncident()) that recover a failed step. See Incidents. Internally the engine is a thin facade: WorkflowExecutor runs the advance loop, IncidentManager carries the operator incident actions and InstanceRecovery the cron recovery sweeps, all backed by small leaf services (token lineage, variable resolution, deadline math, definition resolution, the incident store). Depend on WorkflowEngineInterface; the split is an internal detail.
  • 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_inbox_views Expose work items to Views: the personal task lists, the tenant task overview and the actions that open and complete a task. See Views.
orchestra_delegation Absence cover: for a period, one user acts on another's tasks. See Delegation.
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 Start a workflow from a Webform submission, or collect one during an interactive wait and resume on submit. See External interaction.
orchestra_interaction_task Run an interaction plugin as a logged-in operator's inbox task (the identity doorway), so the same interaction serves a public link or an in-site task. See External interaction.
orchestra_interaction_operation Run an interaction plugin as a pull-based operation instead: the work item is stamped operation mode, so it is acted on from the assignee's pending-actions list under their own identity rather than pushed to the inbox. See Human tasks.
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_presentation The markup and styles the surfaces share: the status tag, the lifecycle marker, the labeled column and the card row they lay out in, each a theme hook a theme overrides once for every surface, plus their component CSS. Depends on nothing but the engine, so a site with the Views integration and no UI still styles its rows.
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_notification The shared notification core and the Notify workflow node: resolve an audience and dispatch a channel-neutral notification event. See Notifications.
orchestra_inbox_notification Dispatch a notification when a human task is assigned, reassigned or times out. See Notifications.
orchestra_interaction_notification Dispatch a notification carrying the capability link when a branch parks on a notify-on-arrival interaction node. See Notifications.
orchestra_mail Default email channel: turns each notification event into one email per recipient. A dumb channel, it decides neither who nor when. See Notification delivery.
orchestra_easy_email Deliver notifications through Easy Email templates instead, an alternative to Orchestra Mail. See Notification delivery.
orchestra_domain Resolve the active tenant from the current domain, binding each domain to a tenant. See Multi-tenancy.
orchestra_payment Take a payment as a workflow step: a payment interaction node and a settlement subscriber, backed by the Kessai payment engine. Experimental. See Payment.
orchestra_examples Ready-to-run example workflows. See Integrations.
orchestra_interaction_webform_examples A ready-to-run submission-validation workflow (submit, review, request changes, modify, process) built on the Webform interaction.
orchestra_payment_example A ready-to-run workflow that takes a payment through the payment submodule and the Kessai simulator gateway. Experimental.

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): TaskDecision {
    // Do work, then advance...
    return TaskDecision::Advance;
    // ...or park to wait for an external signal.
    // return TaskDecision::Park;
  }

}

A plugin that returns TaskDecision::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::resumeWithPayload($token, $payload), writing the payload to the task's payload variable (if it declares one, so outgoing flows can route on it) and signals the token.

A node declares that variable in its node settings: a Payload variable name and a Completion 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 what it was resumed with. The two fields are defined once in CompletionConfigTrait and reused across every node type that writes a completion. With no payload variable a resume just continues with no routing signal (a timeout, for instance, has nowhere to write its outcome), so give a wait a payload variable whenever its outgoing flows need to tell a timeout from a normal resume.

Both that and signal() return whether this call claimed the parked token. Only one caller can: the claim is a guarded flip, so a second one, a re-queued timeout or a double-clicked link, is a no-op that answers FALSE. Gate anything a completion does besides the resume itself on that answer, or a lost race fires it twice for one completion.

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, $payload);
$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; a token that is unknown, not parked, or in another tenant is a harmless no-op. The tenant is checked by the subscriber rather than left to each caller, because the event carries an opaque id and nothing about an id says which tenant it belongs to.

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 CANCELED state, 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 re-dispatches 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).