Recipes
Recipes can introduce config in three ways: direct config files shipped in
the recipe's config/ directory, config actions that
create or update config entities at runtime, and extension installs via the
recipe's install: key. Config actions are covered in their own page; direct
config files and extension installs are covered here.
Why two enforcement mechanisms are needed
Within a recipe, the lock enforces langcodes via two mechanisms with complementary scopes:
| Mechanism | Covers within a recipe | When it runs |
|---|---|---|
hook_entity_presave |
Config entities created by config actions at runtime; config entities from modules installed via install: (once eventually saved outside the syncing window) |
At save time, before the entity is written to storage |
RecipeAppliedEvent subscriber |
Config files in the recipe's own config/ directory |
After the entire recipe has completed |
Why presave alone is not enough for recipe config/ files:
When RecipeRunner installs modules listed in install:, it sets
isSyncing=TRUE, which causes ConfigInstaller to skip entity saves entirely
for those modules' config. Those entity configs are deferred and presave never
fires for them during the recipe. The subscriber covers the recipe's own
config/ files after the fact.
For the recipe's own config/ entities, presave does fire during
RecipeConfigInstaller's entity->save() calls — so those entities are
normalized twice: once by presave at save time, and once by the subscriber via
a raw configFactory->getEditable()->save() after the recipe completes. The
double write is harmless (both write the same lock language), and unavoidable:
hook_entity_presave has no way to know it is running inside a recipe
application.
Why the subscriber alone is not enough:
The subscriber only reads the recipe's own config/ directory — it does not
see config entities created at runtime by config actions, nor the deferred
entity config from install: modules. Those are handled exclusively by
hook_entity_presave.
Direct config files
Config files in a recipe's config/ directory are imported by
RecipeConfigInstaller. The installer saves all config first, then runs
FullyValidatable schema validation on entity types that opt in.
How langcodes are determined without a lock
Direct config files are imported as-is. Whatever langcode value is in the
YAML file is what gets saved. A missing langcode key normalizes to the site
default language before the save.
Entity types that carry the FullyValidatable schema marker are validated after
being saved. An empty or unknown langcode will save successfully and then fail
validation — leaving the entity in the database with the bad langcode and
aborting the recipe with a 500 error.
Entity types without FullyValidatable are never validated; any langcode value
(including invalid ones) is accepted silently.
How the lock changes this
When a lock is active, hook_entity_presave normalizes every config entity's
langcode to the lock language at save time — before RecipeConfigInstaller
runs its FullyValidatable validation. So even entities that shipped with an
invalid or empty langcode are saved with a valid langcode and validation passes.
After the recipe completes, the RecipeAppliedEvent subscriber performs a
second normalization pass over all recipe config names as a safety net,
calling updateConfigForLockedLanguageSwitch() on each one.
Outcome matrix — direct config files
always_valid entities carry the FullyValidatable schema marker;
maybe_invalid entities do not. RecipeConfigInstaller runs FullyValidatable
validation after saving — so empty or unknown langcodes on always_valid
entities cause a 500 without a lock. maybe_invalid entities are never
validated, so any langcode is accepted silently.
All maybe_invalid entities are grouped in the valid-langcode recipe because
they never cause a validation error regardless of langcode value — there is no
FullyValidatable to trigger a 500. The empty and unknown recipes contain only
always_valid entities, where the 500 behavior is the point being tested.
| Entity | Type | Shipped langcode | No lock | Lock = de |
|---|---|---|---|---|
lock_recipe_request |
always_valid | xx (valid installed) |
xx |
de |
lock_recipe_other |
always_valid | yy (valid installed) |
yy |
de |
lock_recipe_missing |
always_valid | (no key) | en (site default) |
de |
lock_recipe_empty |
always_valid | (empty) | (empty) ⚠ then 500 ⚠ | de, 200 |
lock_recipe_unknown |
always_valid | zz (unknown lang) |
zz ⚠ then 500 ⚠ |
de, 200 |
lock_recipe_maybe_request |
maybe_invalid | xx (valid installed) |
xx |
de |
lock_recipe_maybe_other |
maybe_invalid | yy (valid installed) |
yy |
de |
lock_recipe_maybe_empty |
maybe_invalid | (empty) | (empty) ⚠ 200 | de, 200 |
lock_recipe_maybe_unknown |
maybe_invalid | zz (unknown lang) |
zz ⚠ 200 |
de, 200 |
⚠ Invalid or empty langcode persists in storage — either FullyValidatable
validation fires after the save and aborts with 500 (leaving the entity with the
bad langcode in the database), or maybe_invalid has no validation and the bad
value is silently accepted with 200.
Extension config installed via a recipe
When a recipe's install: key causes a module to be installed, RecipeRunner
calls the standard ModuleInstaller with config.installer->setSyncing(TRUE)
active for the duration. This has two consequences:
- The standard
ConfigInstalleris used (notRecipeConfigInstaller), soFullyValidatablevalidation does not run — the same as a standalone module install. - The lock's
hook_modules_installedimplementation checks$is_syncingand skips the rewrite batch when syncing is true.
Despite the batch being skipped, the lock still normalizes langcodes because
hook_entity_presave fires on every entity->save() call inside
ConfigInstaller::createConfiguration(). Presave rewrites the langcode to the
lock language before the entity is written to storage, same as any other save.
Without a lock, locale's hook_modules_installed ignores $is_syncing and
queues its rewrite batch normally — the same batch that runs on a standalone
module install via the UI.
| Entity | Shipped langcode | No lock | Lock = de |
|---|---|---|---|
lock_recipe_ext_en |
en |
en |
de (via presave) |
lock_recipe_ext_missing |
(no key) | en |
de (via presave) |
lock_recipe_ext_xx |
xx |
xx (valid installed) |
de (via presave) |
lock_ext_invalid |
zz |
zz ⚠ (no FullyValidatable check) |
de (via presave) |
⚠ Invalid langcode silently persists — the standard module installer (used here
via the recipe's install: key) does not run FullyValidatable validation, so
the bad value is accepted with no error.
Comparison: standalone vs recipe module install
| Standalone module install | Recipe module install | |
|---|---|---|
$is_syncing passed to hook_modules_installed |
FALSE |
TRUE |
| Lock's rewrite batch runs | Yes | No — skipped because $is_syncing |
| Lock normalization still happens | Via batch | Via hook_entity_presave |
| Locale's rewrite batch runs | Yes | Yes — locale ignores $is_syncing |
FullyValidatable validation runs |
No | No |
| End result for langcodes | Same | Same |
Config actions
Config actions inside recipes are handled the same way as standalone config
actions — see Config actions for the full outcome matrix
and flow diagram. The short version: hook_entity_presave normalizes langcodes
at save time, and the RecipeAppliedEvent subscriber does not cover
action-created entities.
Flow diagram — direct config file import
flowchart TD
A[Recipe applies config file] --> B{Lock set?}
B -- No --> C[Save entity with shipped langcode\nor en if key missing]
C --> D{FullyValidatable?}
D -- No --> E[Done — any langcode accepted]
D -- Yes --> F{Langcode valid?}
F -- Yes --> G[200 OK]
F -- No --> H[500 — entity saved with bad langcode]
B -- Yes --> I[hook_entity_presave overwrites\nlangcode → lock language]
I --> J[Save with lock language]
J --> K[FullyValidatable validation passes\n200 OK]
Related pages
- Config actions — full config action outcome matrix and flow diagram
- Module install — standalone module installs (outside of recipes)
- UI forms — form-based config entity creation