Skip to content

Extending Orchestra

Orchestra is built to be extended. This page describes the public extension surface, the part marked @api and covered by the 1.x backward-compatibility promise, and how to add your own behavior.

What is public

Classes and interfaces tagged @api form the supported contract. Within the 1.x line their method signatures will not change in a breaking way. Everything else, in particular the concrete service implementations tagged @internal (WorkflowEngine, WorkItemManager, OrchestraAudit, AttachmentManager), is mechanics that may change between minor releases: depend on the matching interface, not the implementation.

Plugin types

Each plugin type is a discovery directory, an attribute and a base class. To add one, drop a class in your module's src/Plugin/<Type>/ directory, give it the attribute, and extend the base class (or implement the interface directly).

Plugin type Directory Attribute Base class / interface
Task type Plugin/TaskType #[TaskType] TaskTypeBase / TaskTypeInterface
Gateway Plugin/Gateway #[Gateway] GatewayBase / GatewayInterface
Flow condition Plugin/FlowCondition #[FlowCondition] FlowConditionBase / FlowConditionInterface
Split Plugin/Split #[Split] SplitBase / SplitInterface
Join Plugin/Join #[Join] JoinBase / JoinInterface
Timeout action Plugin/TimeoutAction #[TimeoutAction] TimeoutActionBase / TimeoutActionInterface
Node feature Plugin/NodeFeature #[NodeFeature] NodeFeatureBase / NodeFeatureInterface
Audience Plugin/Audience #[Audience] AudienceBase / AudienceInterface
Variable provider Plugin/VariableProvider #[VariableProvider] VariableProviderBase / VariableProviderInterface
Deadline provider Plugin/DeadlineProvider #[DeadlineProvider] DeadlineProviderBase / DeadlineProviderInterface

A variable provider computes process variables for a run, and contributes them wherever a variables node selects it.

A deadline provider says when a parked task's wait runs out, and every timeout names one. Ship your own when the deadline belongs to a live subject the run does not own: it is asked as the task parks, so it reports that subject as it stands rather than as some earlier step recorded it.

A timeout action that resumes a task with an outcome of its own declares it, so the editor can tell an author what to route on:

#[TimeoutAction(
  id: 'my_action',
  label: new TranslatableMarkup('...'),
  resumesWith: ['expired'],
)]

Declare nothing when the action leaves the task parked (a reminder, a released claim), or when the outcome is the author's own setting, as resume's is. Like provides, this is documentation rather than a contract: nothing rejects an action that resumes with an outcome it did not declare. Getting it wrong is quiet, which is the reason to declare it at all - a flow condition naming an outcome nothing announces is not an error, it is a branch never taken, and it surfaces the first time the timeout fires.

A flow condition answers evaluate($variables), and summary($variables): a one-line diagnostic of what it tested and the value it read, which the engine puts on the incident it raises when a branch dead-ends. Extending FlowConditionBase gives you a generic rendering (the settings as they stand), but a condition that reads process variables should override summary() and name the value it saw, since that is what identifies the cause. formatValue() renders one value unambiguously (NULL, TRUE, a quoted string). It is only called to explain a failure, never while routing.

An audience resolves who a node notifies (getRecipients()). An audience that can also staff a user task additionally implements AssignmentInterface (extending AudienceInterface with getCandidates()/getViewerTokens()) and extends AssignmentBase; a pure notification audience (a fixed external email) extends AudienceBase alone and never appears in the task assignment editor.

getCandidates() returns what the audience resolved to and nothing else. When every audience a node names comes back empty, the step asked for a restriction and did not get one: rather than offer it to every viewer, the engine halts the token before it parks and raises an incident, so no task is created and an operator can fix the audience and retry.

An assignment that restricts nobody returns NULL from getCandidates(), which is what "open to everyone" is called everywhere else, including how such a step reads back from storage. That is the only way a step is open to all: a human node naming no audience is refused when the model is saved.

A task type can opt into extra capabilities by implementing the matching capability interface: SubprocessTaskInterface (launch a child process), ParkingInterface (park the token and wait), PluginSequenceFeatureInterface (compose node features), WorkItemPresentationInterface (inbox presentation), OrchestraContextAwareInterface (receive the running token and instance).

Signaling failure from a task

When a task's execute() throws, the engine classifies the failure by the exception it gets (see Incidents). The markers live in Drupal\orchestra\Exception:

  • Throw a RetryableException for a transient failure the engine should retry per the node's retry policy, dead-lettering to an incident only once the attempts are spent.
  • Throw an OutcomeException($outcome, $reason) for a definite outcome the workflow should route on: the engine records $outcome on the node's payload variable (and $reason on its message variable, if configured) and advances the token, so an outgoing flow branches on it. Neither retried nor an incident.
  • Throw a FatalException, or let any other exception propagate, for a failure a retry cannot fix. An unmarked exception is treated as fatal: the engine raises an incident at once. So only declare a failure retryable when a later, identical attempt genuinely might clear it.

Service contracts

These interfaces are safe to type-hint, decorate or mock:

  • WorkflowEngineInterface (orchestra.engine): start instances, advance, signal, cancel and read or write process variables.
  • WorkItemManagerInterface (orchestra.work_item_manager): create, claim, reassign and complete human tasks, and decide who may act on one (canAct() / checkActionAccess(), see Asking whether someone may act). isAssigneeOrStandIn() answers the narrower question a surface asks to decide which of a holder's actions to offer.
  • TenantContextInterface (orchestra.tenant_context): resolve the active tenant.
  • TenantResolverInterface: a service tagged orchestra.tenant_resolver that decides the active tenant; return a tenant machine name or NULL to defer. Also a CacheableDependencyInterface, because a resolver is the only thing that knows what it read to reach its answer (a host, an OAuth consumer), and anything caching a tenant-scoped decision has to inherit that.
  • OrchestraAuditInterface (orchestra.audit): emit lifecycle audit events.
  • DelegationResolverInterface (orchestra.delegation_resolver): who is standing in for whom. The shipped implementation answers "nobody", and orchestra_delegation decorates it with stored delegations; a site whose absences already live in an HR system, an LDAP attribute or a group module can decorate it instead and keep its own source of truth, with no change to any task, surface or query. An implementation owes the contract two rules: cover does not chain (one hop), and every answer is per tenant. See Delegation.
  • AttachmentManagerInterface (orchestra_content.attachment_manager): bind content entities to a running instance.
  • OrchestraReturn (orchestra.return): the seam for the operator return-to-list behavior. A completion surface calls resolve() (or resolveTo() for a form value) with its own landing as the fallback, so a task opened from a pending-actions list returns there; a list calls embed() (or the shared PendingActionLinksTrait) to add the target to an action link. Redirects are internal-only. A collect step that hands off (a webform, an off-site payment) instead set()s the target server-side, keyed by the token and the operator, and get()s it when the step resumes.

The entity contracts (ProcessInstanceInterface, TokenInterface, VariableInterface, WorkflowInterface, TenantInterface, WorkItemInterface, AttachmentInterface) are likewise part of the public read API.

Events

The engine dispatches typed domain events you can subscribe to:

  • InstanceStartedEvent: an instance has been created and its variables seeded, but it has not run yet. It fires inside the start transaction, before the start token is placed, so a subscriber can wire an external entity to the instance (it has its id) atomically with the start, with the link in place before the run. A subscriber that throws rolls the whole start back, so keep them quick. (A plain entity insert hook is not enough: it fires before the variables are seeded, so a subscriber could not yet tell which entity the run is for.) The matching reverse link is the instance id, the integer primary key, which an external table can hold as a foreign key for a Views relationship.
  • TaskTimedOutEvent: a parked task's notify timeout fired without resuming it; turn it into a reminder or alert. See Notifications.

Asking whether someone may act

Two forms of one decision, and which you want depends on what you do with the answer:

  • canAct($task, $account): bool is the verdict, and allocates nothing. Use it where the answer is consumed and thrown away, above all per row in a list.
  • checkActionAccess($task, $account): AccessResultInterface is the same verdict carrying its reason and its cacheability. Use it where it is merged into something cached: a route access check, or a result you combine with andIf().

The distinction matters because the verdict moves for reasons the calling code cannot see. It reads the task, the account's roles (a role:editor audience token is minted from getRoles()), the resolved tenant, and any standing cover, which is dated: it begins and lapses on the clock with nothing written to the database when it does.

So a surface that renders from canAct() must declare those dependencies itself. The inbox columns do it once per display rather than once per row, by declaring what AssignmentMatcher reports:

public function getCacheTags(): array {
  return Cache::mergeTags(
    $this->matcher->getCacheTags(),
    ['user:' . (int) $this->currentUser->id()],
  );
}

public function getCacheMaxAge(): int {
  return $this->matcher->getCacheMaxAge();
}

The account's own tag is not optional: a per-user cache context partitions by uid, so it never fires when that uid's roles change. And the max-age is what makes cover appearing at 09:00 or lapsing at 18:00 reach the screen, since no row and no config changes at those moments. Returning Cache::PERMANENT there leaves a stand-in looking at rows that offer them nothing.

Resolving the tenant

A multi-tenant site decides the active tenant through resolvers. Tag a service orchestra.tenant_resolver, implement TenantResolverInterface, and return the tenant machine name for the request, or NULL to let the next resolver decide. The first non-null answer wins; with none the default tenant applies. The orchestra_domain submodule ships a resolver that maps the current domain to a tenant.

A resolver also declares its own cacheability, so a consumer never has to guess that installing a domain connector made every tenant-scoped answer vary by host. TenantContextInterface aggregates its resolvers, which is what an access check or a rendered list depends on. Declare the contexts your answer varies by and the tags that change it; return Cache::PERMANENT and no tags if you read nothing request-specific.