Problem/Motivation
When a configuration import deletes a configuration entity, ConfigImporter removes it through a low-level Config object rather than through the entity. The config-entity deletion lifecycle is skipped: ConfigEntityBase::preDelete()/postDelete(), each dependent entity's ::onDependencyRemoval(), and delete hooks never run during import.
For config-to-config dependencies this is largely covered by a separate validate-time stand-in: ConfigImportSubscriber validates the change set at ConfigEvents::IMPORT_VALIDATE and blocks an import that would delete configuration another retained config entity depends on. But it can only block — it cannot reproduce ::onDependencyRemoval()'s self-healing (rewriting a dependent to a safe state and keeping it), and it is blind to anything outside the config graph.
The gap is for dependents and side effects outside the config graph that ::onDependencyRemoval() exists to protect:
- Content entities that reference configuration. A content-to-config reference is not a config dependency, so
ConfigImportSubscribercannot see it and does not block. The config entity is deleted at the storage level,::onDependencyRemoval()never runs, and content is left referencing something that no longer exists. - Non-config state managed via these hooks:
state, generated files or assets, external systems.
Concrete downstream impact: the Canvas module tracks each component with a Component config entity whose ::onDependencyRemoval() switches the component to a safe fallback source when its backing code component is removed, so content still renders. Deleting the code component through the entity API triggers that self-heal and the component is kept. A drush config:import that removes the code component and its Component entity skips the lifecycle: both are deleted at the storage level, the fallback never applies, and every page that used the component white-screens (WSOD), recoverable only by editing the database. See #3573022: [upstream] Data loss: `drush config:import` deletes config (e.g. code component + component config entities) and bypasses config system data integrity checks.
Root cause: ConfigImporter::importConfig() handles a delete operation with a raw Config object and calls Config::delete(), which fires only the ConfigCrudEvent::DELETE event; the config entity is never instantiated, so ConfigEntityBase::preDelete() — which calls ConfigManager::getConfigEntitiesToChangeOnDependencyRemoval() and each dependent's ::onDependencyRemoval() — never runs. Core already runs that machinery for extension-triggered removals via ConfigManager::uninstall(); the plain config-entity delete path in ConfigImporter does not.
Steps to reproduce
The data-loss reproduction needs a content-to-config dependent, which no core config entity has; see #3573022: [upstream] Data loss: `drush config:import` deletes config (e.g. code component + component config entities) and bypasses config system data integrity checks for the end-to-end WSOD.
Core-only, the mechanism is verifiable with a Content Moderation workflow that config-depends on a node type (ContentModeration::onDependencyRemoval() removes the bundle when the node type is deleted):
- Deleting the node type via the entity API self-heals the workflow: the bundle is removed and the workflow is retained.
- Deleting only the node type via config import is blocked by
ConfigImportSubscriberbecause the workflow still depends on it — no lifecycle, but no silent damage (asymmetry). - Deleting the node type and the workflow together imports with no lifecycle; harmless only because the workflow is being deleted anyway.
These confirm the bypass but produce no harm on their own: for config-only dependents core either blocks or the skip is moot. Harm requires a dependent outside the config graph.
Proposed resolution
Route config-entity deletions in ConfigImporter through the entity and dependency-removal machinery so preDelete() and ::onDependencyRemoval() run, mirroring what ConfigManager::uninstall() already does for extension-triggered removals. Simple (non-entity) configuration keeps using Config::delete(). Import ordering and secondary write/delete reconciliation are preserved by the importer's existing per-operation re-check.
This does not make core aware of content-to-config references (and not to be confused with config-to-content references, tracked in #2248369: [meta] Deploying configuration that depends on content); it makes a config entity's own ::onDependencyRemoval() effective during import, so a Canvas-style entity can protect its content, and entity-API deletion and config import behave consistently.
Remaining tasks
- Add a kernel test that isolates the defect using a config entity whose
::onDependencyRemoval()has an effect outside its own configuration (for example astatewrite), asserting it fires duringConfigImporter::import()deletions as it does via entity delete. - Assess the entity-API-versus-import asymmetry described in STR: UI self-heals, config import rejects.
- Assess backward compatibility: imports that previously deleted entities via the low-level path will now trigger the deletion lifecycle and its side effects. This is one of those bugs that people might be building on top. Can this happen in a patch or minor version? e.g. how this interacts with existing
ConfigEvents::IMPORT_VALIDATEguards (including Canvas's)? - Fix.
User interface changes
None.
API changes
No public signatures change, but it's a pretty important behavioral change people might be relying on: deleting a config entity via ConfigImporter now runs ConfigEntityBase::preDelete()/postDelete() and dependents' ::onDependencyRemoval(), matching EntityInterface::delete().
Data model changes
None.
LLM disclosure
Drafted with Opus 4.8, tons of edits by this human afterward, typos my own.
Comments
Comment #2
penyaskitoI recognize this might look entirely LLM generated, but sadly isn't. Editing this HTML made me suffer.
Comment #3
wim leersThis seems at least a major bug?
Comment #4
wim leers🤔 Isn't this part of what #2414951: Validate configuration schema before importing configuration should prevent? See https://git.drupalcode.org/project/drupal/-/merge_requests/8519/diffs#no...
Comment #5
vidit.anjaria commented