Skip to content

Authoring models as configuration

An ECA model is a Drupal configuration entity stored in a single file named eca.eca.<id>.yml. Everything ECA needs at runtime lives in that file. You can write it by hand, generate it from a script, or ship it in a recipe, without ever opening a modeler.

This page describes the file format and the rules that are easy to get wrong. For a conceptual introduction to events, conditions, and actions, start with ECA concepts.

The config entity is canonical

The diagram is not the model. ECA reads and executes eca.eca.<id>.yml alone. The BPMN XML or modeler JSON that a visual editor produces is a derived presentation layer, and storing it is optional: the Modeler API supports three strategies for raw modeler data, selected per model through the storage key.

none
Do not store raw model data at all. Every model in the ECA Library uses this setting.
third-party
Store the raw data inside the model's own third_party_settings.
separate
Store the raw data in a separate modeler_api.data_model.* config entity.

A hand-written model therefore needs no diagram data, and ECA treats it exactly like a model drawn in a modeler.

The one tradeoff: no stored geometry

Without raw modeler data there are no stored node coordinates. When you open such a model in the Workflow Modeler, it lays the diagram out automatically. The result is readable but it is not the layout you would have chosen. This is cosmetic and has no effect on execution.

The file skeleton

The following model writes a log entry whenever an article is saved while published. Every top-level key ECA reads is present; uuid is generated on import.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
# eca.eca.log_published_article.yml
langcode: en
status: true

# Modules and config entities this model needs. Drupal enforces these on
# import, so an incomplete list makes the model fail to install.
dependencies:
  module:
    - eca_content
    - eca_log
    - modeler_api

# Metadata for the modeler UI and the library. See "Modeler API metadata".
third_party_settings:
  modeler_api:
    modeler_id: workflow_modeler
    storage: none
    label: 'Log published article'
    documentation: 'Writes an ECA log entry when a published article is saved.'
    changelog: 'Initial version'
    tags:
      - example
    version: 1.0.0

# The machine name. It must match the file name. There is no top-level
# `label` key; the model's label lives under `third_party_settings`.
id: log_published_article

# Execution order when several models react to the same event. Lower runs first.
weight: 0

# TRUE turns the model into a reusable template instead of a live model.
template: false

# Events are the entry points. Each key is a component ID you choose.
events:
  event_article_update:
    plugin: 'content_entity:update'
    label: 'Update article'
    configuration:
      type: 'node article'
    successors:
      - id: action_log
        condition: condition_is_published

# Conditions are a flat pool, referenced by ID from successor links.
conditions:
  condition_is_published:
    plugin: eca_entity_field_value
    configuration:
      entity: ''
      field_name: status
      expected_value: '1'
      operator: equal
      type: value
      case: false
      negate: false

# Gateways are a flat pool too. Empty when the model uses none.
gateways: {  }

# Actions are a flat pool, referenced by ID from successor links.
actions:
  action_log:
    plugin: eca_write_log_message
    label: 'Write log message'
    configuration:
      channel: eca
      severity: 6
      message: 'Article [entity:title] was saved while published'
    successors: {  }

There is no top-level label

label is not a key of the eca.eca.* config entity. It is absent from the schema in eca/config/schema/eca.schema.yml, from entity_keys and from config_export, so ConfigEntityBase::toArray() strips it on import, and Eca::label() reads third_party_settings.modeler_api.label instead. Configuration validation reports 'label' is not a supported key.

A stray top-level label does not break an import, because config entities are re-serialized through toArray() first. It does cause permanent drift in a configuration sync directory: active storage can never contain the key, so drush config:status reports the model as changed forever.

Conditions and actions are pools, not children

conditions and actions sit at the top level of the file, keyed by component ID. They are not nested inside the event or the action that uses them. Flow is expressed only through successors, which reference those keys. Nesting an action inside an event produces configuration that does not match the schema, and the flow is never built. This is the mistake to check for first when a hand-written model does nothing.

Component IDs are yours to choose. Any ID referenced from a successor must match ^[A-Za-z0-9_]+$, and IDs only have to be unique within the file. Modelers generate IDs such as content_entity__update_1 or Activity_0l4w3fc. Readable names are easier to review in a merge request.

The successor shape

successors is a list, and every item is a mapping with exactly two keys:

id
The component ID of the next step. It must resolve to a key in the actions or gateways pool.
condition
The component ID of a condition in the conditions pool, or an empty string for an unconditional link.
1
2
3
4
5
successors:
  - id: action_log
    condition: condition_is_published
  - id: action_send_mail
    condition: ''

One link, one condition

condition is a single machine name, not a list. A link can therefore carry at most one condition. To require two conditions, chain them through the Chain action for AND condition action, which exists to provide the second link:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
# Excerpt. The conditions pool is omitted for brevity.
events:
  event_article_update:
    plugin: 'content_entity:update'
    label: 'Update article'
    configuration:
      type: 'node article'
    successors:
      - id: and_gate
        condition: condition_status_changed
actions:
  and_gate:
    plugin: eca_void_and_condition
    label: AND
    configuration: {  }
    successors:
      - id: action_log
        condition: condition_is_published

The model now logs only when the status field changed and the new value is published, which is how you detect a transition rather than a state.

See Combining conditions for the AND and OR patterns and their behavior.

Write condition: '' rather than omitting the key. Drupal does not enforce this: eca.eca.* is not marked FullyValidatable, so a successor missing condition produces no configuration validation violations. ECA enforces it at runtime instead. It reads $successor['condition'] directly, with no null-coalesce, in eca/src/Entity/Eca.php (lines 432, 465, and 482), so an omitted key raises an "Undefined array key" warning every time the link is evaluated, which is fatal under a strict error handler.

Plugin IDs, including derivatives

plugin takes the plugin ID, which is not the human-readable label. Many ECA event plugins are derivatives, and their IDs contain a colon:

Plugin ID Label
content_entity:insert Insert content entity
content_entity:update Update content entity
content_entity:presave Presave content entity
form:form_validate Validate form
user:login Login of a user
eca_base:eca_custom ECA custom event

Quote any value containing a colon so YAML does not read it as a mapping: plugin: 'content_entity:update'.

To find the ID for a plugin, open its page in the plugin reference. The page title is the label, and the last segment of the page URL is the plugin ID with the derivative colon replaced by an underscore. The page for "Update content entity" is /plugins/eca/content/events/content_entity_update, so the plugin ID is content_entity:update.

Most action and condition IDs contain no colon and match the page URL directly: eca_write_log_message, eca_entity_field_value, eca_entity_field_value_changed.

The entity type and bundle selector

Events that act on content entities take a single type string containing the entity type ID and the bundle, separated by one space:

1
2
configuration:
  type: 'node article'

_all is the wildcard and works in either position:

Value Matches
'node article' Articles only
'node _all' Any node bundle
'user _all' Any user account
'_all _all' Every content entity of every bundle

The modeler shows this as a select list labeled "Type (and bundle)" with - any - for _all, which is why the underlying string format is easy to miss when writing the file by hand.

Modeler API metadata

third_party_settings.modeler_api holds everything that describes the model rather than defining its behavior. The Modeler API schema declares these keys:

Key Purpose
modeler_id The modeler that owns the model, for example workflow_modeler or bpmn_io.
storage Raw data strategy: none, third-party, or separate.
data The raw modeler data, or a reference to it when stored separately. The reference is the literal prefix hash: followed by the MD5 hash of the data, for example data: 'hash:3b55347d2b54f8f7a4df06351adc0ef0'. A bare hash without the prefix is not recognized. Omit the key with storage: none.
label Display label of the model. This is the model's only label: Eca::label() reads it from here, and there is no top-level label key.
documentation Long-form description shown in the modeler and the library.
changelog Free-text change history.
tags List of strings used for grouping and filtering.
version Version string of the model, for example 1.0.0 or v2.
annotations Notes attached to components, each with text and assigned_to.
colors Per-component fill and stroke overrides.
swimlanes Named groupings of components.

All of them are optional. modeler_id is the one worth always setting, because it decides which modeler opens the model for editing.

Not part of this envelope

template and status are top-level keys of the ECA config entity, not Modeler API settings. The modeler UI reports them as template and executable, which it derives from those top-level values, so do not add keys with those names under modeler_api.

label is the opposite case. It belongs only under modeler_api and is not a top-level key of the ECA config entity.

Gateways do not branch

ECA's configuration schema restricts gateways.*.type to the single value 0, and the gateway object performs no branching when it executes. A gateway passes control to all of its successors, subject only to the condition on each outgoing link. It gives you neither exclusive choice nor an AND join.

Use gateways to keep a diagram readable, for example to merge two or more flows into one shared path. For AND semantics, chain conditions through the Chain action for AND condition action. See Gateways for the current state of the feature.

1
2
3
4
5
6
7
gateways:
  merge_point:
    label: Merge
    type: 0
    successors:
      - id: action_log
        condition: ''

Rebuild the subscribers after import

A model that never fires

ECA does not listen to every event. It keeps a state entry listing only the events used by enabled models, and rebuilds that list whenever an ECA config entity is saved or deleted. If the list goes stale, the model exists, looks correct in the modeler, and never runs. Rebuild it explicitly:

1
drush eca:subscriber:rebuild

The command is cheap and idempotent, so include it in your deployment and test scripts after every configuration import. A model that imports cleanly and then silently does nothing is a common false report of a broken ECA installation.

For the import mechanisms themselves, see Importing models. For verifying a model from the shell, see Testing models.

Gotcha: a colon followed by a space in free text

ECA calculates a model's configuration dependencies by scanning every string in the file for token-like patterns. A string that contains no recognizable token is passed on whole to an entity type lookup. If such a string also contains a colon, Drupal core rejects it, because a colon marks a plugin derivative:

Bundle entity types are not supported directly.

This only affects free text with a colon and no token, for example message: 'Result: done'. A message such as 'Result: [entity:title]' is safe, because entity:title is recognized as a token first.

Current ECA releases guard against this. If your model has to install on older releases as well, avoid the pattern. Use a different separator:

1
2
3
4
5
# Risky on older ECA releases.
message: 'Import finished: 12 nodes updated'

# Safe everywhere.
message: 'Import finished -> 12 nodes updated'

Validate the file with the MCP server

The ECA Guide publishes a Model Context Protocol server at https://ecaguide.org/mcp. Alongside the documentation search tools it exposes four tools for authoring a model, which are useful whether you write the file yourself or hand the job to an AI agent. The agent skill wires them up automatically.

The order to use them in:

  1. search_plugins finds a plugin ID by purpose.
  2. get_plugin_schema returns that plugin's config keys, their form types, defaults, allowed values and provided tokens, plus a configuration: fragment you can paste into the file.
  3. get_model_examples returns real shipped models to compare shape against.
  4. Write the eca.eca.<id>.yml file.
  5. validate_model checks the finished file.

get_plugin_schema is the tool that saves the most time, because the generated plugin reference documents each setting by its UI label, while the file needs its config key. Guessing the translation between the two is the single most common source of errors in a hand-written model, and the type string described in The entity type and bundle selector is a good illustration of why.

Run validate_model before every import. It performs a static check with no Drupal bootstrap, catching dangling successors, values outside a setting's allowed list, a condition written as a list, missing required keys, malformed bundle selectors, and plugins referenced from the wrong pool. Findings carry one of three severity levels:

error
Provably wrong. Fix it before importing.
warning
Suspect, but not provable from the data available.
needs-site
Cannot be checked without a live site, typically because the module providing a plugin was not installed on the site the plugin catalog was generated from.

This matters more for ECA than for most configuration, because ECA fails silently. A structurally broken model imports without complaint, looks correct in the modeler, and then never fires.

Checklist before importing

  • The file name matches id.
  • The model's label is set under third_party_settings.modeler_api.label, and there is no top-level label key.
  • Every successors entry has both id and condition.
  • Every id in a successor exists in the actions or gateways pool.
  • Every non-empty condition exists in the conditions pool.
  • Plugin IDs containing a colon are quoted.
  • dependencies.module lists every module whose plugins the model uses, plus modeler_api.
  • drush eca:subscriber:rebuild has run after the import.