Skip to content

Exporting a model as a recipe

An ECA model can be exported as a Drupal recipe, and every model in the ECA Library is exported that way each time the library is regenerated. The recipe carries the model itself, the modules it needs, and the configuration a consuming site must have for the model to do anything.

Three keys under a model's third_party_settings.modeler_api decide what that configuration set contains. All three are provided by the Modeler API module:

Key Controls
recipes Other recipes the generated recipe applies before its own steps.
export_config Additional config object names the recipe ships.
config_actions Config actions the recipe performs on the consuming site.

A model carries further modeler_api settings that the export reads but that do not shape the config set: modules adds to the recipe's install: list and to the require block of composer.json, while summary and documentation supply the recipe's description and its README.md. None of them is covered here.

For what happens on the receiving end, see Importing Models.

How the config set is built

The exporter assembles one list of config names, in this order:

  1. The model's own config object, eca.eca.<id>.
  2. The transitive closure of the model's dependencies.config. Each name there is read from active configuration and its own dependencies.config is walked in turn, recursively, until nothing new is found.
  3. Every name in export_config that the first two steps have not already collected.

Each name in the resulting list is then routed to exactly one destination:

Config name Destination
Starts with user.role. An ensure_exists and a grantPermissions action in recipe.yml
Shipped by one of its own dependency modules under config/install or config/optional A config.import entry in recipe.yml, keyed by that module
Anything else A file in the recipe's config/ directory, with uuid and _core stripped

The rows are tested in that order and the first match wins. user.role. is tested first, so a role always becomes a pair of actions, even when a module ships it under config/install and the second row would otherwise claim it.

The model's own config object takes one further step on that third path. Besides uuid and _core, the exporter removes third_party_settings.modeler_api.data and modeler_id from it and forces storage: none, so the recipe carries the model rather than the diagram of whichever modeler authored it, and a re-save on the consuming site cannot reinstate a payload the recipe never shipped.

config_actions takes no part in that. Its entries are written into recipe.yml under config.actions after the derived role actions, so a config action the model expresses wins over a derived one addressing the same config name. That win is a wholesale replacement of the value at that key rather than a merge: a role that is both in the dependency closure and named in config_actions keeps only what the model states, and loses its derived ensure_exists and grantPermissions entirely.

recipes becomes the top-level recipes: list in recipe.yml, verbatim and in order. It is omitted when the setting is empty and the recipe has not been exported before; on re-export a recipes: list already in recipe.yml is preserved, so clearing the model setting does not remove it. It never affects composer.json, so a recipe named there is applied but not required as a package. Core recipes such as core/recipes/article_content_type are always present, but a contrib recipe has to reach the site some other way.

Unlike a module name, which the exporter checks against the extension list and skips with a warning when nothing on the site provides it, a recipe name is never validated. A path that resolves to no recipe is written out silently and fails only when the recipe is applied.

Direction decides what you declare

Configuration either points at the model or the model points at it, and only one of those two directions is visible to the exporter. Almost everything else on this page follows from that.

Upstream config exports itself

Configuration the model depends on is upstream: a view an action executes, a field a condition reads, the content type that field belongs to. Drupal records those names in the model's own dependencies.config, so the exporter finds them and walks their closure. Nothing is declared.

Dependent location fields shows the closure at work. The model's dependencies.config names six objects, three field instances and three field storage objects. The recipe's config/ holds nine. Seven of them come from the walk: those six plus node.type.location, which the model never mentions and which arrives because each field instance depends on the content type and the walk follows that second hop on its own. The other two are the ones steps 1 and 3 predict — the model's own eca.eca.eca_lib_0040.yml, and core.entity_form_display.node.location.default.yml, which the model declares in export_config. All three assembly steps are therefore visible in a single directory.

Downstream config is invisible

Configuration that depends on the model is downstream: a block whose plugin is the model, an entity form display carrying a field widget action that references the model. Drupal records that relationship on the other object, not on the model, so the model's dependencies.config never mentions it and the exporter has no way to discover it.

That is not merely an omission. The exporter deletes and rebuilds config/ on every export, so a file placed there by hand survives only until the next regeneration. Anything in a recipe's config/ that the model does not name is not reproducible.

Warning

A downstream config object has to be named by the model, either in export_config or in config_actions. Nothing else keeps it in the recipe.

Choosing between the two settings

Once a downstream object has to travel with the recipe, the choice is between shipping it and acting on it.

Choose export_config when all of the following hold:

  • The object will not exist on the consuming site.
  • Its content is site-neutral.
  • Creating it wholesale is the right outcome.

For names routed to config/ or to config.import, export_config is create-if-absent — and that is core's non-strict import behavior rather than anything the exporter does. For a user.role.* name it is not create-if-absent at all: the derived grantPermissions action resolves to Role::grantPermission(), which appends to a role the site already has. Config actions run in their own phase, after configuration is created and unaffected by strict.

Choose config_actions when any of the following holds:

  • The object already exists on the consuming site and has to be modified.
  • The correct content depends on the consuming site.
  • The exported file would carry identity from the generating site.

Editorial desk is the site-dependent case. The model contributes three blocks — block.block.editorial_desk, block.block.editorial_desk_activity and block.block.editorial_desk_counter — and where a block belongs depends on the consuming site's default theme. Shipping them as files would freeze the generating site's theme into the recipe, so the model uses one config_actions entry per block, each with core's placeBlockInDefaultTheme action, which reads the theme on the consuming site instead. The recipe's config/ directory therefore holds only the model and the view the model executes, both of them upstream config the exporter found by itself.

Using both on one config name

The two settings combine on the same config name, where they are complementary rather than redundant. export_config guarantees the object exists, and config_actions modifies it whether the recipe created it or found it. Core creates configuration first and applies config actions second, so that order holds on both paths.

Field widget actions with ECA is the canonical example of the combined shape, and it uses all three settings at once:

 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
recipes:
  - core/recipes/article_content_type
config_actions:
  -
    config: core.entity_form_display.node.article.default
    actions:
      setComponentThirdPartySetting:
        -
          component: title
          provider: field_widget_actions
          settings:
            ac9e78e9-5143-4377-a780-2bad69383942:
              button_label: 'Suggest a title'
              direct_fill: false
              enabled: true
              plugin_id: 'eca_field_widget:eca_lib_0041.Event_suggest_title'
              weight: 0
        -
          component: body
          provider: field_widget_actions
          settings:
            2170c37f-8865-4d44-aeaa-265d9a6e4ad9:
              button_label: 'Insert byline'
              direct_fill: true
              enabled: true
              plugin_id: 'eca_field_widget:eca_lib_0041.Event_insert_byline'
              weight: 0
export_config:
  - core.entity_form_display.node.article.default

The buttons live in the third-party settings of an entity form display, and Drupal creates entity form displays lazily, so on a fresh site that object does not exist at all. export_config makes sure it does. The config action then merges the two buttons into its components, on a fresh site and on an established one alike, without replacing a display the site has already customized.

Two traps

Recipes turn strict mode off, deliberately

A fresh export writes strict: false under config: in recipe.yml. A re-export supplies it only when recipe.yml carries no strict key, so a value already in the file is preserved. Core's default is the opposite of the generated one. With no strict key, a recipe is strict, and strict means every config object the recipe ships must be either absent from the site or identical in content to the recipe's copy. Anything else aborts the whole recipe with RecipePreExistingConfigException. In non-strict mode the recipe imports only the names that active configuration does not have yet, which is exactly the create-if-absent behavior described above.

export_config is therefore non-destructive only because of that opt-out. A maintainer who sets strict: true in a published recipe.yml by hand turns every export_config entry into a potential hard failure on any site whose copy of the object differs. Core also accepts a list of config names in place of true. That narrows the identity check to the names it lists, so an object outside the list may pre-exist and differ without aborting the recipe, while one inside it still aborts. It does not otherwise soften the setting: core tests strict for truth rather than for identity when it decides whether to filter the import down to names the site does not have, and a non-empty list is true, so a list switches that filter off exactly as true does. Even then nothing is overwritten, because the config installer separately imports only names that active configuration does not already hold.

Because the merge preserves it, a hand-set true survives every later export.

A module-provided name never becomes your file

If a name in export_config is provided by one of the config object's own dependency modules under config/install or config/optional, the exporter routes it to config.import rather than writing a file for it. On a site that does not have the object, the consuming site receives the module's pristine version, and whatever customization the generating site holds is lost. On a site that already has it, the non-strict import filters the name out and nothing happens at all. Nothing fails and nothing warns either way.

Modifying such an object requires config_actions. Naming it in export_config gets the object created, but never with your content.

Further behavior worth knowing

The data always comes from active configuration

export_config names an object. It never carries a copy of one. The exporter reads every name from the generating site's active configuration at export time, so whatever drift that site has accumulated is what ships.

A missing name is a warning, not a failure

A name in export_config that does not exist in active configuration produces a warning that identifies the object, and is skipped. The export completes. A name the dependency closure has already collected is a harmless no-op, because the exporter appends only names it has not seen.

Nothing validates a config action

The actions value of a config_actions entry has the configuration schema type ignore, so Drupal validates none of it. A misspelled config action ID or a malformed payload survives the export into recipe.yml and fails only when the recipe is applied.

Why config_actions is a list and not a map

A recipe keys its config actions by config name. A model cannot do the same, because every config name contains dots and Drupal's config system rejects a dot in a config array key. config_actions is therefore a list of entries, each with a config key and an actions key, which the exporter converts into the recipe's name-keyed map. Three kinds of entry are skipped rather than written under a key a recipe could not apply: one that is not an array at all, one whose config is not a string, and one whose config is absent or an empty string.

A missing actions key is not one of them. It defaults to an empty map, so the entry is written out with no actions beneath it and applies cleanly while doing nothing. It is the one malformed entry the export keeps, and it looks exactly like a working one.

Re-exporting rewrites the recipe

Every export removes and rewrites README.md, recipe.yml, composer.json and the whole config/ directory. recipe.yml and composer.json are merged into what was there before, which is what preserves a hand-set strict. config/ is not merged. It is deleted and rebuilt from the model, and the export warns for every .yml directly in it that is not about to be rewritten, naming the file and asking for its config object to be added to the model. That scan is not recursive, so a stray .yml in a subdirectory is deleted with everything else and never named.

The merge treats config actions asymmetrically. Every pre-existing user.role.* action is dropped before the generated set is overlaid, while a pre-existing action on any other config name is kept. A role action added to recipe.yml by hand is therefore gone at the next export, and a hand-added non-role action survives it — the one place where hand-editing a recipe is not simply undone.