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) andTenant; plus the runtime content entitiesProcessInstance,Token,Variable,WorkflowVersion(an immutable snapshot a running instance is pinned to) andIncident(a recorded execution failure), each carrying atenant. - 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 avariablesnode computes from the run itself),DeadlineProvider(when a parked task's wait runs out),TimeoutAction(what a task's timer does when it fires) andAudience(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()andcancel(), plus the incident operations (retryIncident(),resumeIncident(),skipIncident(),cancelIncident(),failFromIncident()) that recover a failed step. See Incidents. Internally the engine is a thin facade:WorkflowExecutorruns the advance loop,IncidentManagercarries the operator incident actions andInstanceRecoverythe cron recovery sweeps, all backed by small leaf services (token lineage, variable resolution, deadline math, definition resolution, the incident store). Depend onWorkflowEngineInterface; the split is an internal detail. - The queue worker:
orchestra_advanceadvances 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).