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

  1. Multilingual site with language.negotiation:url.source = path_prefix.
  2. Base config: prefixes.en = 'en'.
  3. Domain override (via domain_config): on the English domain, prefixes.en = ''.
  4. Normal navigation shows unprefixed English URLs (override active).
  5. Go to /admin/modules, enable a module. Drupal's submit handler calls ModuleInstaller::install(['...']) then batch_process().
  6. 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=....
  7. 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

Command icon 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

mably created an issue. See original summary.

mably’s picture

Issue summary: View changes

mably’s picture

Status: Active » Needs review
mably’s picture

Issue summary: View changes

Swapped the MR to a cleaner approach: tag domain.negotiation_context with persist instead of re-negotiating in hook_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. DomainNegotiator and DomainConfigFactoryOverride receive 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 just ModuleInstaller).

Added kernel test DomainContextAfterKernelRebuildTest exercising 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.

mably’s picture

CI on MR !365 surfaced two legitimate failures that the persist tag exposed. Both fixed as part of the MR:

  1. 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 made addConfigurationsToCurrentDomain() 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 $data in DomainConfigFactoryOverride::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.
  2. DomainConfigUiSavedConfigTest::testSavedConfig had 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 to getOriginal('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.

  • mably committed 5e264cf7 on 3.x
    fix: #3586001 DomainNegotiationContext is lost after mid-request...
mably’s picture

Status: Needs review » Fixed

Now that this issue is closed, review the contribution record.

As a contributor, attribute any organization that helped you, or if you volunteered your own time.

Maintainers, credit people who helped resolve this issue.

Status: Fixed » Closed (fixed)

Automatically closed - issue fixed for 2 weeks with no activity.