Problem
When a form submission triggers ModuleInstaller::install() or ModuleInstaller::uninstall() mid-request, Drupal rebuilds the service container (DrupalKernel::updateModules() -> rebuildContainer()). The rebuild replaces every stateful service with a fresh instance -- including Drupal\domain\DomainNegotiationContext, which the DomainSubscriber had previously populated on KernelEvents::REQUEST. Nothing re-populates it after the rebuild.
Any code running after the rebuild in the same request that reads per-domain config through DomainConfigFactoryOverride then sees an empty context:
public function loadOverrides($names) {
$domain_id = $this->domainNegotiationContext->getDomainId();
if ($domain_id) {
$storage = $this->getStorage($domain_id);
return $storage->readMultiple($names);
}
return []; // <-- hits this after rebuild, no override applied
}Result: the ConfigFactory resolves language.negotiation (and any other domain-overridden config) from the base storage only. On sites that rely on the override for URL path prefixes (e.g. an override that sets the default language's prefixes.en to empty string), this is directly user-visible.
Reproduction
- Multilingual site with
language.negotiation:url.source = path_prefix. - Base config:
prefixes.en = 'en'. - Domain override (via
domain_config): on the English domain,prefixes.en = ''. - Normal navigation shows unprefixed English URLs (override active).
- Go to
/admin/modules, enable a module. Drupal's submit handler callsModuleInstaller::install(['...'])thenbatch_process(). - The batch redirect URL is built after the container rebuild.
ConfigFactory->get('language.negotiation')reads base config without the override ->prefixes.en = 'en'-> redirect URL becomes/en/batch?id=.... - On sites where
/en/*is not a real reachable path (because English runs on its own domain or because the rewrite stack does not serve it), the redirect 404s.
Diagnostic trace
Every HTTP request shows the same ordering: DomainConfigFactoryOverride::loadOverrides('language.negotiation') fires once before DomainSubscriber::onKernelRequestDomain (middleware / early bootstrap lookups) with domain_id = NULL, then again after kernel.request with domain_id = <active-domain>. The two calls land in distinct ConfigFactory cache entries (keys include the override's getCacheSuffix(), so 'und' vs the active domain id), and most code paths hit the post-negotiation entry with the override applied.
On a module install/uninstall request, the submit handler runs updateKernel(), the DomainNegotiationContext instance is replaced by an empty one, and subsequent reads (e.g. batch_process()'s URL generation) see domain_id = NULL again. No override applies.
Proposed fix
Tag domain.negotiation_context with persist so DrupalKernel transfers the same DomainNegotiationContext instance from the old container to the new one across mid-request rebuilds.
services: domain.negotiation_context: class: Drupal\domain\DomainNegotiationContext tags: - { name: persist }
The context is pure state with no dependencies, so it is safe to share across the rebuild. Services that depend on it (DomainNegotiator, DomainConfigFactoryOverride) pick up the persisted instance through normal constructor injection in the rebuilt container. No re-negotiation work, no new hook plumbing.
This covers every mid-request kernel rebuild regardless of trigger, not just the two ModuleInstaller paths.
Environment
- Drupal core 11.3.x, PHP 8.3.
- domain 3.x-dev.
- domain_config enabled, with an override on
language.negotiation.
Issue fork domain-3586001
Show commands
Start within a Git clone of the project using the version control instructions.
Or, if you do not have SSH keys set up on git.drupalcode.org:
Comments
Comment #2
mably commentedComment #4
mably commentedComment #5
mably commentedSwapped the MR to a cleaner approach: tag
domain.negotiation_contextwithpersistinstead of re-negotiating inhook_modules_installed/hook_modules_uninstalled.DrupalKernel::persistServices()then transfers the same instance across the mid-request container rebuild.The context is pure state with no dependencies, so it is safe to share across the rebuild.
DomainNegotiatorandDomainConfigFactoryOverridereceive the persisted instance through normal constructor injection in the rebuilt container. Simpler than re-running negotiation, avoids new hook plumbing, and covers every rebuild trigger (not justModuleInstaller).Added kernel test
DomainContextAfterKernelRebuildTestexercising both install and uninstall paths in one run. Verified locally: fails cleanly on the pre-fix tree (null !== 'example_com'), passes with the persist tag applied.Also updated the "Proposed fix" section of this issue accordingly.
Comment #6
mably commentedCI on MR !365 surfaced two legitimate failures that the persist tag exposed. Both fixed as part of the MR:
DomainConfigOverrideEditable::save()latent bug: when a form saves a subset of a config's keys, the schema-cast step copies$data(base + current form changes) back onto$moduleOverrides, overwriting previously-saved override values for keys the current form did not touch. This never fired before because the test process's empty context madeaddConfigurationsToCurrentDomain()silently register[null], so saves fell through to base storage. With the context preserved, the override path actually runs and the bug surfaces. Fixed by initializing$datainDomainConfigFactoryOverride::getOverrideEditable()with a deep merge of base + existing domain data so the cast-copy step sees the correct override values. This is an independently user-facing fix: anyone hitting the multi-form pattern on a registered domain would have lost override fields on partial saves.DomainConfigUiSavedConfigTest::testSavedConfighad an assertion reading\Drupal::configFactory()->get('system.site')->get('name')expecting the base value. That only worked when the test process's context happened to be empty. Switched togetOriginal('name', FALSE)so the assertion is robust regardless of active domain.All 76 domain + domain_config kernel tests and the two FunctionalJavascript tests pass locally. MR CI is green. Description on the MR has been rewritten to reflect the final state.
Comment #8
mably commented