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:
- The model's own config object,
eca.eca.<id>. - The transitive closure of the model's
dependencies.config. Each name there is read from active configuration and its owndependencies.configis walked in turn, recursively, until nothing new is found. - Every name in
export_configthat 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 | |
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.