Problem/Motivation
When a recipe installs a module, RecipeRunner::installModules() wraps the install in setSyncing(TRUE). In that state, ConfigInstaller::installDefaultConfig() creates only simple config. Config entities in the module's config/install are skipped.
Those entities are created later — and only if the recipe lists the module under config: import:. Then RecipeConfigInstaller::installRecipeConfig() creates them in one dependency-sorted batch, together with all other recipe config. Two problems follow:
- A recipe without
config: import: {module_name: '*'}never creates the module's config entities at all. The module is installed, but config it cannot function without is missing. (Which, arguably, is not allowed: no config is guaranteed to exist.) This is the install-parity gap described in #3478921: A recipe should install new modules in the same way/result as modules installed via the UI or CLI., and the motivation of #3560179: Support installing all module configuration entities via recipes. - With the import list, the entities are created (too) late
: after module install hooks, and interleaved with unrelated recipe config in the batch. Code that runs during the batch (entity save hooks, storage handlers) can observe the module installed but its own config entities absent.
This includes config entities that declare an enforced dependency (see the change record for enforced dependencies) on the module that ships them. Core has no official way for a module to declare that it requires a config entity. This combination is the closest thing:
- the module ships the entity in
config/install - the enforced dependency binds the entity's lifecycle to the module — it is deleted when the module is uninstalled.
A module that does both is saying "this config is mine, and it exists as long as I do".
Real-world example: Drupal Canvas' image.style.canvas_parametrized_width
Canvas ships that with dependencies.enforced.module: [canvas], and its code loads that image style whenever image props are processed.
Real-world failure: Canvas MR 1554. A recipe installs Canvas plus a module shipping canvas.js_component.* entities. In the sorted batch, canvas.js_component.* sorts before image.style.* (no declared dependency between them, name order breaks the tie). Saving the js_component triggers a storage handler that needs the image style. It does not exist yet. Canvas has carried a debug_backtrace() workaround for this since #3515646: Add automated <img srcset> generation, because there is no API to detect that a recipe is being applied (#3613607: Add RecipeRunner::isApplying() to determine if a recipe is being applied, without confusing it with a config sync).
Steps to reproduce
- Create a module with a
config/installconfig entity that declares an enforced dependency on that module. - Create a recipe that lists the module under
install:, without aconfig: import:entry for it. - Apply the recipe.
- The module is installed. The config entity does not exist. Installing the same module via the UI or
drush pm:installcreates it.
Proposed resolution
During RecipeRunner::installModules(), install the config entities from each module's config/install that declare an enforced dependency on a module in the set being installed ("enforced-owned" below). All other config entities keep the current behavior: created only via config: import: or the recipe's config directory.
Why this subset is safe (and unlike #3478921: A recipe should install new modules in the same way/result as modules installed via the UI or CLI. should not break anything):
- Every
config/installentity must already be installable at module install time — that is the normal, non-recipe flow. Installing the enforced-owned subset at that same moment is therefore safe, as long as the subset's config dependencies stay within the subset (the sharp edge below). - Recipes deliberately curate which config gets imported. That freedom does not apply here: a recipe cannot sensibly omit config that exists exactly as long as the module it is installing.
RecipeOverrideConfigStoragealready wraps the module's config directories duringinstallModules(), so a recipe shipping its own version of such an entity still wins. Override semantics are unchanged.DefaultConfigMode(InstallSimple/InstallEntities/All) already exists inConfigInstaller::installDefaultConfig(); this adds one more filter.
Edge case: an enforced-owned entity that depends on a config entity outside the subset reintroduces the same ordering problem one level down. The subset must be installed in dependency order among itself, and an unmet dependency outside the subset must produce a clear error (see also #3556278: Unmet config dependencies should fatal when applying recipes). Concretely: an enforced-owned entity depending on a non-enforced sibling in the same config/install installs fine via the UI/CLI (the whole set is installed together), but the subset rule alone would not install that sibling.
👉 However, AFAICT this is not an actual real-world scenario. Perhaps we should validate that enforced config can only depend on other enforced config (or on modules/themes)? That would eliminate this edge case entirely, at the cost of a deprecation path for any existing config that violates it.
Remaining tasks
- Agree on the subset rule (enforced dependency on a module in the set being installed).
- Decide the failure mode for enforced-owned entities with unmet dependencies outside the subset.
- Write an MR with test coverage: recipe installing a module with an enforced-owned entity, with and without
config: import:, and a recipe overriding such an entity.
User interface changes
None.
Introduced terminology
None.
API changes
None expected. Possibly one new DefaultConfigMode case; @internal recipe classes change behavior.
Data model changes
None.
Release notes snippet
Recipes now install config entities that declare an enforced dependency on a module being installed by the recipe, at module install time — matching the result of installing that module via the UI or CLI. Config entities without an enforced dependency are still only created when listed in the recipe's config: import: section or shipped in its config directory.
Comments
Comment #2
wim leersI used AI to piece all these moving parts together, but reviewed and revised the issue summary.
It's longer than I like, but there's a lot of moving parts to this! 😬
Comment #3
wim leersComment #4
alexpottI agree we should tackle this problem space - it would make recipes easier to make and maintain if extensions could detail this. I wonder though if we should do the simple and more consistent thing and use the config/optional and config/install directories to make this distinction. Then if modules have config that should be in optional we can open an issue to move the config. Using the config enforced dependencies seems like a bit of hidden feature and a bit surprising.
A tangential thought...
Maybe we should take a step back from just the recipe side of this and zoom out. What this issue details is the fact that the Canvas module provides a config entity that has to exist and should never be deleted. It feels like we need a generic way for an extension to indicate this and then not only should the Recipe system ensure it is installed, but also, the config entity system should prevent it from being deleted - no? Otherwise things are inconsistent. The language module has an implementation of this using it's locked flag which potentially we could look to generalise here. On the other hand enforcing existence and then allowing it to be deleted during extension uninstall feels like an icky problem.
Comment #5
nedjoYes. From the summary in #3283506: Document responsibility for required extension-provided configuration (slightly updated to fix links and such):
Have we considered adding the
::isLocked()method toConfigEntityInterface?