Problem/Motivation

Domain negotiation currently runs as a KernelEvents::REQUEST event subscriber at priority 256. HTTP middlewares that read Drupal config — such as CleanTalk (priority 210) for example — execute before the active domain is set. This means domain_config overrides don't apply, and those middlewares always get the default config instead of domain-specific config.

Example of a Drupal middleware priority stack:

  • 300 — ReverseProxy
  • 250 — CORS / Ban
  • 210 — CleanTalk
  • 200 — PageCache
  • 100 — KernelPreHandle (loads modules, pushes request to stack)
  • 50 — Session

Event subscribers (including DomainSubscriber) only fire after all middlewares have run.

Proposed resolution

Extract domain negotiation into a lightweight HTTP middleware at priority 220 (after ReverseProxy which corrects HTTP_HOST from proxy headers, before CleanTalk at 210).

The middleware temporarily pushes the request to RequestStack, calls DomainNegotiator::getActiveDomain(), then pops it. No loadAll() is needed — in Drupal 11, DomainAliasHooks uses #[Hook('domain_request_alter')] so OOP hooks dispatch via the container without loading .module files.

DomainSubscriber stays unchanged — getActiveDomain() checks isNegotiated() and returns the cached domain, so the event subscriber's negotiation call becomes a no-op while redirect/access logic runs as before.

Remaining tasks

  • Create DomainNegotiationMiddleware in domain/src/StackMiddleware/
  • Register it in domain.services.yml as http_middleware at priority 220
  • Test with domain_alias, domain_config, and modules that read config at middleware time

Issue fork domain-3575069

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:

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

Status: Active » Postponed

Move domain negotiation into HTTP middleware

Problem

Domain negotiation currently runs as a KernelEvents::REQUEST event subscriber at priority 256. Any HTTP middleware that reads Drupal config — such as CleanTalk at priority 210 — does so before the active domain is set, because event subscribers only fire after the full middleware stack has executed. This means DomainConfigFactoryOverride returns default config instead of domain-specific overrides for those middlewares.

Approach

Add a new DomainNegotiationMiddleware (http_middleware, priority 220) that negotiates the active domain early in the middleware stack. Priority 220 places it:

  • After ReverseProxy (300) — so HTTP_HOST is already corrected from proxy headers
  • Before CleanTalk (210) — so domain_config overrides are active when CleanTalk reads config

The middleware:

  1. Pushes the request onto RequestStack so DomainNegotiator reads the proxy-corrected hostname (not raw $_SERVER)
  2. Calls DomainNegotiator::getActiveDomain() to negotiate
  3. Pops the request to avoid a double entry when HttpKernel pushes it again later

DomainSubscriber is unchanged — getActiveDomain() checks isNegotiated() and returns the cached domain, so the event subscriber's negotiation call becomes a no-op while its redirect/access logic runs as before.

Execution flow after the change

Priority Middleware / Event Action
300 ReverseProxy Fixes HTTP_HOST from proxy headers
250 CORS / Ban
220 DomainNegotiation Push request, negotiate domain, pop request
210 CleanTalk Reads config → domain_config overrides work ✓
200 PageCache
100 KernelPreHandle loadAll(), push request (negotiation already cached)
50 Session
HttpKernel → KernelEvents::REQUEST
256 DomainSubscriber getActiveDomain() returns cached domain (no-op); redirect/access logic runs as before

Module loading and Drupal version handling

Domain negotiation triggers entity type discovery via EntityTypeManager::getStorage('domain') and hook dispatch via ModuleHandler::alter('domain_request', ...). The middleware handles this differently depending on Drupal version:

  • Before Drupal 11.1: #[LegacyHook] procedural functions are the registered hook implementations. The middleware calls ModuleHandler::loadAll() unconditionally before negotiation so that .module files are available (e.g. domain_alias_domain_request_alter()). This is idempotent — the later KernelPreHandle call is a no-op.
  • From Drupal 11.1+: OOP hooks are dispatched directly via the container without needing .module files. The middleware attempts negotiation without loadAll(). On a warm entity type cache (99.9% of requests), this means zero overhead. On a cold cache (e.g. during update.php), entity type discovery needs procedural hook_entity_type_build implementations — the middleware catches the failure, calls loadAll(), and retries.

Changes

  • New: domain/src/StackMiddleware/DomainNegotiationMiddleware.php
  • Modified: domain/domain.services.yml — added the middleware service definition
  • Unchanged: DomainSubscriber, DomainNegotiator

mably’s picture

Issue summary: View changes
mably’s picture

Status: Postponed » Needs review
mably’s picture

Project: Domain » Domain Extras

mably’s picture

New domain_early_negotiation submodule

Moved the early domain negotiation middleware from the domain module into a standalone submodule in domain_extras. Enabling the module = enabling the feature — no separate config toggle needed.

Files

File Purpose
domain_early_negotiation.info.yml Module definition, depends on domain:domain
domain_early_negotiation.services.yml Registers middleware, config subscriber, and hook class
domain_early_negotiation.module #[LegacyHook] delegate for Drupal 10.6 compatibility
domain_early_negotiation.routing.yml Settings form at /admin/config/domain/early-negotiation
domain_early_negotiation.links.menu.yml Menu link under Domain admin section
config/install/domain_early_negotiation.settings.yml Default priority: 220
config/schema/domain_early_negotiation.schema.yml Config schema for the priority setting
src/StackMiddleware/DomainNegotiationMiddleware.php Moved from domain, always active (no $enabled flag)
src/DomainEarlyNegotiationServiceProvider.php Reads priority from bootstrap config, sets container parameter, overrides middleware tag priority
src/EventSubscriber/ConfigSubscriber.php Invalidates container when priority changes
src/Form/DomainEarlyNegotiationSettingsForm.php ConfigFormBase with priority number field (1–299)
src/Hook/DomainEarlyNegotiationHooks.php Implements hook_domain_config_ui_disallowed_configurations_alter() to prevent per-domain overrides of this module's settings
tests/src/Kernel/DomainEarlyNegotiationTest.php 5 kernel tests: container parameter, middleware service, config subscriber, priority change rebuild, domain negotiation
docs/domain_early_negotiation/index.md Mkdocs documentation page

Key design decisions

  • No enabled config flag — installing the module is the toggle. This eliminates the container parameter overhead when the feature is not wanted.
  • Priority is compiled into a container parameter via DomainEarlyNegotiationServiceProvider, so there is zero runtime config read overhead.
  • The module's settings config is excluded from Domain Config UI overrides via hook_domain_config_ui_disallowed_configurations_alter() — middleware priority must be the same across all domains.

  • mably committed ec42ed80 on 3.x
    feat: #3575069 Move domain negotiation to HTTP middleware for early...
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.