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 trioProcessInstance,TokenandVariable(content entities, 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), andGateway(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) withstart(),advance(),signal(),spawn()andcancel(). It is the only stateful actor. - 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_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).