Problem/Motivation

Domain allows content to be assigned to different target audiences. The target audience may present itself on a different domain:

However, the target group could also be presented as a subdirectory if a single hostname is important:

Currently this can be implemented with https://www.drupal.org/project/country_path. However, given how tightly coupled the domain path prefix is with the Domain logic, maybe this use case can be supported by Domain itself?

Proposed resolution

Support a path prefix for domains to allow a single hostname to host multiple domains

Remaining tasks

  1. Write a merge request
  2. Review
  3. Commit

User interface changes

To be determined

API changes

To be determined

Data model changes

To be determined

Issue fork domain-3575947

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

idebr created an issue. See original summary.

mably’s picture

Status: Active » Postponed (maintainer needs more info)

I do not see how this relates to the Domain module.

mably’s picture

Status: Postponed (maintainer needs more info) » Closed (won't fix)

Closing as out-of-scope based on the architecture analysis below:

Architecture assessment

The Domain module is hostname-only by design. The negotiation flow is:

  1. DomainSubscriber (priority 256) triggers negotiation on KernelEvents::REQUEST
  2. DomainNegotiator::negotiateActiveHostname() extracts HTTP_HOST
  3. DomainStorage::loadByHostname() does exact config entity lookup
  4. hook_domain_request_alter() allows override (used by domain_alias)

Path prefix routing is a fundamentally different concern — closer to language negotiation or the country_path module already mentioned in the issue.

Possible approaches

1. Close as out-of-scope — Path prefix routing doesn't relate to the Domain module's concept of "domain". Document how hook_domain_request_alter() can be used for custom matching instead.

2. Sub-module domain_prefix — Implement via hook_domain_request_alter():

  • Add an optional path_prefix property to the Domain entity
  • Sub-module implements the hook to match request path against configured prefixes
  • Strip the prefix from the request path before routing
  • ~50 lines of core logic, leverages existing extension points
  • Risk: path stripping interacts with language negotiation, path aliases, and domain_path in complex ways

3. Core integration in DomainNegotiator — Extend the negotiator to check both hostname + path prefix:

  • Add path_prefix to Domain entity schema
  • Modify loadByHostname() to also filter by prefix
  • Modify DomainSubscriber to strip prefix from request
  • Most invasive, but cleanest long-term solution
  • Risk: breaks the "domain = hostname" mental model

Recommendation

Given that:

  • country_path already solves this use case
  • Path prefix routing interacts with language negotiation (both use path prefixes)
  • The domain module ecosystem (domain_access, domain_path, domain_alias) all assume hostname-based matching
  • Adding path prefix support would require changes across multiple sub-modules

Closing as out-of-scope seems the most appropriate. For custom needs, hook_domain_request_alter() provides a clean extension point — it has access to the current request and can match domains based on any criteria, including path prefixes.

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.

mably’s picture

Will investigate whether approach 2 can be implemented as a submodule of the domain_extras project.

mably’s picture

mably’s picture

Status: Closed (won't fix) » Needs review

While working on the domain_prefix submodule for domain_extras, I eventually realized that the feature is not very complicated to add.

@idebr could you give a try to this issue's MR and tell me what you think?

mably changed the visibility of the branch 3575947-support-a-path to hidden.

mably’s picture

Summary

Adds optional path_prefix property to Domain entities, allowing multiple domains to share a single hostname distinguished by URL path prefixes (e.g. example.com/myprefix/... vs example.com/...). This is useful for sites that cannot add new hostnames (corporate firewalls, shared hosting, single-origin CDN) but need separate domain contexts.

Domain entity changes

  • New property: path_prefix (string, empty by default) on Domain entity with getPathPrefix() / setPathPrefix() methods.
  • Config schema: added path_prefix to domain.record.* mapping.
  • Uniqueness constraint: DomainUniqueHostnameConstraintValidator now validates the (hostname + path_prefix) pair — two domains may share a hostname if their prefixes differ.
  • Post-update hook: initializes path_prefix: '' on all existing domain records.

Domain negotiation

  • New public method: DomainNegotiatorInterface::negotiateByPathPrefix() — given multiple domains sharing a hostname, matches the request path against each domain's prefix (longest prefix first, empty prefix as fallback).
  • Storage method: DomainStorageInterface::loadMultipleByHostname() — loads all domains for a given hostname.
  • setRequestDomain() now calls negotiateByPathPrefix() when multiple candidates share a hostname.

Path processing

  • New service: DomainPrefixPathProcessor — handles both inbound and outbound path processing.
  • Inbound (priority 350, before language at 300): reads the active domain's prefix from the negotiation context and strips it from the request path.
  • Outbound (priority 50, after language at 100): prepends the prefix to generated URLs. Uses $options['domain'] if set, otherwise the active domain.
  • URL structure: /myprefix/fr/about-us = domain prefix + language prefix + path alias.

Domain Alias integration

  • DomainAliasHooks::domainRequestAlter() now calls negotiateByPathPrefix() after alias resolution, so that alias + prefix combinations work correctly (e.g. alias.example.com/myprefix/page resolves the alias then selects the prefixed domain).
  • 3 new kernel tests covering alias + prefix, alias + no prefix, and wildcard alias + prefix scenarios.

Admin UI

  • Domain form: new "Path prefix" textfield with description explaining the feature.
  • Machine name: JS widget combines hostname + prefix for the machine name source (e.g. example.com.myprefixexample_com_myprefix).
  • Domain list: new "Prefix" column in the admin overview table.

Drush

  • domain:add: new --path-prefix option.
  • domain:list: path_prefix column in default output fields.

Documentation

  • New page: docs/domain/path_prefix.md — comprehensive documentation covering negotiation flow, matching rules, configuration, interaction with all submodules, technical details, and performance.
  • Domain docs: new "Path prefix" section linking to the dedicated page, and new "Drush commands" section documenting all 11 domain Drush commands with example output.
  • Domain Alias docs: updated "Key principle" section (canonical hostnames + production redirect aliases), new "Performance" section with detailed breakdown, expanded Drush section covering all 5 alias commands with example output.

Tests (28 new test methods)

  • DomainPrefixTest (10 tests): property getter/setter, config persistence, uniqueness constraint, prefix negotiation, longest-match-first, inbound/outbound path processing.
  • DomainPrefixLanguageTest (5 tests): domain prefix + language prefix interaction for inbound/outbound processing, correct ordering.
  • DomainAliasPrefixTest (3 tests): alias + prefix, alias + no prefix, wildcard alias + prefix.

Files changed

28 files, 2105 insertions, 41 deletions.

mably’s picture

Performance impact analysis

This feature introduces four new code paths that run per request. Here is a detailed breakdown of the cost in each scenario.

1. Domain negotiation (setRequestDomain)

Before: loadByHostname() — calls loadByProperties(['hostname' => $value]), returns the first match.

After: loadMultipleByHostname() — same loadByProperties() call, but returns all matches instead of just the first.

Scenario Before After Difference
Single domain per hostname (most sites) 1 loadByProperties() 1 loadByProperties() + count() check → early return One integer comparison (count <= 1). Negligible.
Multiple domains sharing a hostname N/A (not possible before) loadByProperties() + usort() on 2–5 entries + one str_starts_with() per candidate Pure in-memory operations on a tiny array. Negligible.

loadByProperties() operates on config entity storage which is cached in memory after the first read per request. No additional database queries are introduced.

2. Path prefix disambiguation (negotiateByPathPrefix)

Called once during negotiation and potentially once more from Domain Alias (if an alias matches).

  • Single candidate: early return at count <= 1. Cost: zero.
  • Multiple candidates: usort() on 2–5 entries by string length (integer comparison), then one str_starts_with() per candidate until a match is found. All in-memory, no I/O.

3. Inbound path processor (DomainPrefixPathProcessor::processInbound)

Runs once per request during routing (priority 350, before language at 300).

Scenario Cost
No domain negotiated yet getDomain() returns null → instanceof check → return. One comparison.
Active domain has no prefix (most sites) getDomain() + getPathPrefix() returns '' → string comparison → return. Two property reads + one comparison.
Active domain has a prefix Same as above + one str_starts_with() + one substr(). Three string operations.

4. Outbound path processor (DomainPrefixPathProcessor::processOutbound)

Runs once per generated URL (priority 50, after language at 100).

Scenario Cost
External URL !empty($options['external']) → return. One check.
No domain (no $options['domain'], no active domain) instanceof check × 2 → return. Two comparisons.
Active domain has no prefix (most sites) getPathPrefix() returns '' → return. Three checks total.
Active domain has a prefix Same as above + one string concatenation + one addCacheContexts(). Trivial.

For a typical page rendering ~100 URLs on a site that does not use prefixes, this adds ~300 cheap boolean/instanceof checks total — well under a microsecond.

5. Uniqueness constraint (DomainUniqueHostnameConstraintValidator)

Runs only on domain save (admin form or Drush), not on every request.

Before: loaded all domains with matching hostname, failed if any existed (besides self).

After: same loadByProperties() call, but iterates matches comparing path_prefix — only fails if both hostname and prefix match.

Cost difference: one additional getPathPrefix() string comparison per existing domain with the same hostname. Only runs during admin operations. Zero runtime impact.

6. Domain Alias integration

Before: domainRequestAlter() loaded a single domain by alias target ID.

After: loads the target domain, then calls loadMultipleByHostname() to get all domains sharing that hostname, then calls negotiateByPathPrefix().

Scenario Cost
Exact domain match (production, most common) DOMAIN_MATCHED_EXACT early return — unchanged, zero cost.
Alias match, single domain on target hostname One extra loadByProperties() (in-memory, config cache) + count <= 1 early return.
Alias match, multiple domains sharing target hostname Same + usort() + str_starts_with() per candidate (2–5 entries).

The additional loadMultipleByHostname() uses the config entity static cache, so if the target domain was already loaded (which it was, via $domain_storage->load()), the underlying loadByProperties() filters in memory with no additional storage reads.

7. New cache context

The outbound processor adds url.path cache context only when a prefix is actually prepended to a URL. Sites that do not use path prefixes will never see this context added. Sites that do use prefixes will have render cache entries varied by path — this is necessary for correctness and has no impact beyond the expected cache granularity.

Summary

Component When it runs Cost for sites without prefixes
Negotiation Once per request One count() check
Inbound processor Once per request Two property reads + one string comparison
Outbound processor Per generated URL Three cheap checks (external, instanceof, empty string)
Alias integration Only when alias matches Unchanged (early return on exact match)
Uniqueness constraint Admin save only Zero runtime impact
New cache context Never (no prefix) Zero impact

Conclusion: for sites that do not use path prefixes (the vast majority), the overhead is unmeasurable — a handful of boolean checks and empty-string comparisons per request. For sites that use prefixes, the cost is a small in-memory sort and string comparison on 2–5 domain entities, with no additional database or storage queries.

mably’s picture

Architecture comparison: domain path_prefix vs country_path module

Aspect country_path domain path_prefix
Storage Third-party setting on domain entity (domain_path) First-class config property (path_prefix) in schema
Domain detection hook_domain_request_alter() — runs after negotiation, overrides the result Integrated in setRequestDomain() via negotiateByPathPrefix() — part of core negotiation
Hostname input Single field: example.com/usa parsed with explode('/') Separate path_prefix field on the form
Entity class Replaces Domain class entirely via hook_entity_type_build() Adds property to existing Domain entity
Form handler Replaces domain form handler class entirely Adds a field to the existing form
List builder Replaces list builder class entirely Adds a column to the existing list builder
Uniqueness Overrides preSave() on custom entity class Modifies the existing constraint validator
Inbound priority 400 350
Outbound priority 10 50
Inbound method Regex: preg_match + preg_replace str_starts_with() + substr()
Outbound method $options['prefix'] = "$suffix/" . $options['prefix'] Same approach
Prefix source (inbound) Loads active domain from negotiator, reads third-party setting Reads active domain from negotiation context
Language integration Custom LanguageNegotiationCountryPathUrl plugin that parses 2 path segments Processing order — domain prefix stripped before core language negotiation runs
Alias integration Modifies alias patterns to include /country_path, custom validator allowing / in patterns Calls negotiateByPathPrefix() after alias resolution — aliases stay hostname-only
Alias validator Extends DomainAliasValidator to allow / in patterns No alias validator changes needed
Cache context url.country — always added url.path — only added when prefix is prepended
Cross-domain URLs Not handled (uses $options['active_domain']) Uses $options['domain'] with fallback to active domain
Module type Standalone contrib module Built into domain core

Key architectural differences

1. Integration depth: country_path is a bolt-on that replaces 4 classes (entity, form, list builder, alias validator) via hooks. The path_prefix feature is integrated directly into the domain entity, negotiator, and storage — no class replacements.

2. Negotiation flow: country_path runs its detection in hook_domain_request_alter(), meaning the negotiator first picks a domain (potentially wrong), then country_path overrides it. The path_prefix approach loads all hostname candidates upfront and picks the right one during initial negotiation — one pass, no override.

3. Language handling: country_path needs a custom language negotiation plugin that manually parses two path segments (country + language). The path_prefix approach relies on processing order — the domain prefix is stripped at priority 350, then core's LanguageNegotiationUrl at priority 300 sees a clean path. No custom language plugin needed.

4. Alias handling: country_path embeds the prefix into alias patterns (cdn.example.com/usa), requiring a modified validator that allows / in patterns. The path_prefix approach keeps aliases hostname-only and runs prefix disambiguation after alias resolution — aliases don't need to know about prefixes.

5. Performance: country_path uses regex (preg_match + preg_replace) for path stripping. The path_prefix approach uses str_starts_with() + substr() — simpler and faster. country_path always adds a cache context; path_prefix only adds one when a prefix is actually in use.

mably’s picture

Should we merge this? Waiting for your RTBC love.

mably’s picture

Updates since #13

Domain negotiation

  • negotiateByPathPrefix() now returns NULL when no prefix matches the request path, instead of returning an arbitrary fallback domain. The caller in setRequestDomain() handles this by falling through to hook_domain_request_alter() / default domain logic.
  • The early return for 0 or 1 candidates now uses explicit comparisons (=== 0 / === 1) instead of the less readable reset($candidates) ?: NULL.

URL generation (setPath / setUrl)

  • setPath() now includes the domain's path prefix in the base URL. Cost: one getPathPrefix() property read + one string concatenation per call. Negligible.
  • setUrl() now uses DomainPrefixPathProcessor::processInbound() to strip the active domain's prefix from the request URI, then prepends the target domain's prefix. Cost: one service container lookup (singleton, cached after first call), two parse_url() calls (native C function), and one str_starts_with() check. Runs once per domain entity per request (lazy initialization). Negligible.

Domain Alias integration

  • In domainLoad(), domains sharing the same canonical hostname as the active domain (i.e. differing only by path prefix) are now rewritten directly — no per-domain alias lookup needed. This is both simpler and faster than the previous domain ID check + separate elseif.
  • Added error logging when negotiateByPathPrefix() returns NULL after alias resolution (matching the existing pattern for failed domain target loads).
  • Constraint validator now shows the path prefix in the error message when the prefix is non-empty (prefixMessage property on the constraint).

Tugboat

  • Wildcard alias changed from default to local environment — fixes hostname rewriting for both the default and prefixed domains in the preview.

Documentation

  • Tugboat config.yml: comment explaining why --environment=local is needed.
  • Domain Alias docs: admonition about using non-default environments for preview/CI aliases, updated performance section for same-hostname prefix shortcut in environment rewriting.

Tests (31 test methods, up from 28)

  • DomainPrefixTest: 13 tests (was 10) — added testSetPathIncludesPrefix, testSetUrlSwapsPrefix, testSetUrlStripsActivePrefix. Updated testSameHostnameSamePrefixRejected and testValidationConstraint with message assertions.
  • DomainAliasPrefixTest: 4 tests (was 3) — added testEnvironmentRewritePrefixedDomains.
  • DomainPrefixLanguageTest: 5 tests (unchanged).

Updated performance summary

For sites without path prefixes: the only new cost compared to #13 is one extra empty-string comparison in setPath() and one service lookup + two parse_url() calls in setUrl() — both lazy and per-entity. Unmeasurable.

For sites with path prefixes: setUrl() reuses the existing DomainPrefixPathProcessor to swap prefixes (no logic duplication). In domainLoad(), same-hostname domains skip alias lookup entirely. Total overhead remains a handful of in-memory string operations per loaded domain entity.

Files changed

31 files, ~2400 insertions, ~50 deletions.

mably’s picture

It's ultimately much more complicated than I initially thought. 😉

mably’s picture

Gate path prefix feature behind a config toggle

Added a domain.settings:path_prefix boolean (default: FALSE) under Experimental features on the Domain settings page. When disabled, all path prefix components are removed from the container — zero runtime overhead. Follows the same pattern as the existing www_prefix setting.

What the toggle guards

# Component When disabled
1 DomainServiceProvider::alter() Removes domain.prefix_path_processor service + FQCN alias from container
2 DomainNegotiator::setRequestDomain() Uses simple loadByHostname() instead of multi-candidate prefix negotiation
3 DomainHooks::languageNegotiationInfoAlter() Skips swapping core's LanguageNegotiationUrl with LanguageNegotiationDomainUrl
4 DomainAliasHooks::domainRequestAlter() Uses alias target directly instead of prefix disambiguation
5 Domain::setUrl() Skips prefix stripping/prepending for domain-switch URLs
6 DomainForm::form() Hides the "Path prefix" text field
7 DomainListBuilder Hides the "Prefix" column in header and rows

Infrastructure

  • DomainServiceProvider::register() sets domain.path_prefix container parameter from bootstrap config
  • ConfigSubscriber::onConfigSave() invalidates the container when path_prefix changes
  • All guarded components use proper dependency injection (#[Autowire(param:)], constructor injection via create()/createInstance()) — no \Drupal:: static calls except in the Domain entity class where DI is unavailable
  • LanguageNegotiationDomainUrl now implements ContainerFactoryPluginInterface for proper DI

Files changed

domain module:

  • config/install/domain.settings.yml — added path_prefix: false
  • config/schema/domain.schema.yml — added schema entry
  • src/Form/DomainSettingsForm.php — checkbox in experimental features
  • src/DomainServiceProvider.php — sets parameter + removes service when disabled
  • src/DomainNegotiator.php — guards prefix negotiation
  • src/Hook/DomainHooks.php — guards language negotiation swap
  • src/Entity/Domain.php — guards setUrl() prefix logic
  • src/Plugin/LanguageNegotiation/LanguageNegotiationDomainUrl.php — DI via ContainerFactoryPluginInterface
  • src/EventSubscriber/ConfigSubscriber.php — invalidates container on path_prefix change
  • src/Form/DomainForm.php — hides prefix field
  • src/DomainListBuilder.php — hides prefix column
  • tests/src/Kernel/DomainPrefixTest.php — enables setting in setUp
  • tests/src/Kernel/DomainPrefixLanguageTest.php — enables setting in setUp

domain_alias module:

  • src/Hook/DomainAliasHooks.php — guards prefix disambiguation in alias resolution
  • tests/src/Kernel/DomainAliasPrefixTest.php — enables setting in setUp

Other:

  • .tugboat/config.yml — enables path_prefix setting for preview environments
  • docs/domain/path_prefix.md — documents the toggle and updated performance section
  • docs/domain/index.md — moved path prefix under experimental features
mably’s picture

Backward Compatibility Analysis

Interface additions (additive, non-breaking for consumers)

These add new methods to interfaces — not BC-breaking for consumers, but BC-breaking for implementations (any class implementing the interface directly must add the new methods):

  1. DomainInterface — new getPathPrefix(): string and setPathPrefix(string): static. Any custom class implementing DomainInterface directly (not extending Domain) will break.
  2. DomainNegotiatorInterface — new negotiateByPathPrefix(array): ?DomainInterface. Any custom negotiator implementing the interface directly will break.
  3. DomainStorageInterface — new loadMultipleByHostname(string): array. Any custom storage implementing the interface directly will break.

Constructor changes (minor risk)

The following constructors gain a new $pathPrefixEnabled parameter with default FALSE, so existing code using the container is unaffected. Custom service decorators or subclasses overriding create() may need adjustment:

  • DomainNegotiator::__construct()
  • DomainHooks::__construct()
  • DomainAliasHooks::__construct()
  • DomainForm::__construct()

Behavioral changes (non-breaking)

  • Domain::validateHostnameUniqueness() — relaxed from rejecting duplicate hostnames to allowing shared hostnames with different path prefixes. Existing domains have empty prefixes, so the check is equivalent for current data.
  • DomainUniqueHostnameConstraintValidator::validate() — loops over all hostname matches instead of checking just the first. Adds the prefix dimension but does not change behavior for existing single-hostname domains.
  • DomainTestTrait::domainCreateTestDomains() — new $prefixes parameter with default FALSE. Non-breaking for existing callers.

Summary

The commit is mostly backward-compatible when the feature is disabled (default path_prefix: false). The main concern is the 3 interface additions — any third-party code that directly implements DomainInterface, DomainNegotiatorInterface, or DomainStorageInterface (rather than extending the concrete classes) will need to add the new methods. This is standard for a minor version feature addition in Drupal contrib, but it should be documented in the release notes.

mably’s picture

Performance impact summary

What's new at runtime

Component When prefix disabled When prefix enabled
DomainPrefixPathProcessor Service removed from container Registered (inbound 350, outbound 50)
LanguageNegotiationDomainUrl Core's LanguageNegotiationUrl used Overrides core's URL language negotiator
DomainNegotiator::setRequestDomain() loadByHostname() (single match) loadMultipleByHostname() + prefix matching
DomainCacheContext (required) domain replaces url.site in required render cache contexts (unconditional)

Per-request cost breakdown

1. Domain negotiation (runs once, early in kernel boot)

  • Prefix disabled: loadByHostname() → calls loadByProperties(['hostname' => $host]) — in-memory filter on config entities. Same as before.
  • Prefix enabled: loadMultipleByHostname() → same loadByProperties() call (returns all matches instead of first), then negotiateByPathPrefix() — sorts candidates by prefix length (usort, typically 2–5 items) + one str_starts_with per candidate. Negligible overhead — a handful of string comparisons on short strings, cached domain entities already in memory.

2. Inbound path processing (runs once per request)

  • Prefix disabled: Processor not in container — zero cost.
  • Prefix enabled: Reads negotiated domain from context (property access), checks prefix (empty string check or one str_starts_with), strips prefix via substr. ~3 method calls, all string ops.

3. Outbound path processing (runs per generated URL)

  • Prefix disabled: Processor not in container — zero cost.
  • Prefix enabled: Per URL: property read for domain, empty-string check on prefix, string concatenation for $options['prefix'], adds domain cache context to bubbleable metadata. ~5 method calls per URL. For a page generating 50 URLs, that's ~250 trivial method calls — microsecond territory.

4. Language negotiation override (runs once per request, only when language module is active)

  • Prefix disabled: Core's LanguageNegotiationUrl::getLangcode() reads getPathInfo() directly.
  • Prefix enabled: LanguageNegotiationDomainUrl::getLangcode() strips domain prefix from path before language prefix lookup. One extra str_starts_with + substr. No additional config reads — same language.negotiation config as core.

5. Cache context: domain replaces url.site

  • url.site called $request->getSchemeAndHttpHost() — one string read.
  • domain calls $negotiationContext->getDomainId() — one property read on an already-resolved object. Identical cost.

Cache storage impact

Setup Before After
N domains, different hostnames N variants per element N variants (same)
N domains, same hostname + prefixes 1 variant (broken) N variants (correct)
1 domain 1 variant 1 variant (same)

No increase in cache entries for subdomain setups. Path prefix setups get correct per-domain entries where they previously shared a single (incorrect) entry.

The domain.path_prefix guard

The config toggle domain.settings.path_prefix is read once at container build time (BootstrapConfigStorageFactory), set as a container parameter, and used to:

  1. Remove DomainPrefixPathProcessor from the container when disabled
  2. Skip loadMultipleByHostname() + prefix matching in the negotiator
  3. Skip the language negotiation override (hook_language_negotiation_info_alter is a no-op)

When the feature is off, the only runtime cost compared to the previous code is:

  • The domain cache context instead of url.site (identical cost)
  • One bool parameter injected into the negotiator constructor (negligible)

Bottom line: zero measurable performance impact when prefix is disabled, and microsecond-level overhead when enabled.

idebr’s picture

Wow, nice to see the path prefix integrated in Domain. Fine work 🙏

A key insight in the country path module was to update the output for Domain::getPath to include the path prefix. This allows other Domain code to work with path prefixes without any moderations, such as DomainNavBlock:

      $home_url = $domain->getPath();
      $prefix = $domain->getPathPrefix();
      if ($prefix !== '') {
        $home_url = rtrim($home_url, '/') . '/' . $prefix . '/';
      }

See https://git.drupalcode.org/project/country_path/-/merge_requests/19/diffs

The path prefix could probably use some property validation, such as 'no slashes': https://git.drupalcode.org/project/country_path/-/blob/8.x-1.x/src/Count...

mably’s picture

@idebr I tried to implement your suggestions. Let me know what you think.

Add getBasePath(), fix setUrl() for subdirectory installs

Problem

setUrl() used parse_url($request->getRequestUri(), PHP_URL_PATH) to get the current path, then passed it to DomainPrefixPathProcessor::processInbound() for prefix stripping. In subdirectory installs (e.g., Drupal running at /drupal/), getRequestUri() includes the base path, so the raw path is /drupal/fr/admin/config. processInbound() expects the prefix immediately after /, so it fails to find and strip /fr. The prefix then gets prepended at the wrong position: /fr/drupal/admin/config instead of /drupal/fr/admin/config.

Additionally, getPath() returned the raw base URL (scheme + hostname + base_path) without the path prefix, which forced callers like getLink() and DomainNavBlock to manually append the prefix — duplicating logic and risking inconsistency.

setPath() also relied on global $base_path instead of using Symfony's request API.

Solution

New getBasePath() method — returns the raw base URL (scheme + hostname + base_path) without the path prefix. This is what DomainPathProcessor, DomainValidator, DomainForm, and DomainAccessField need, since the prefix is handled separately by DomainPrefixPathProcessor.

getPath() now includes the prefix — appends the path prefix to the base path when set. This eliminates manual prefix additions in getLink() and DomainNavBlock::build().

setUrl() rewritten — uses $request->getPathInfo() (which excludes the base path) for prefix manipulation, then rebuilds the URL using getBasePath(). Without the path prefix feature enabled, preserves the original simple behavior: scheme + hostname + requestUri.

setPath() uses $request->getBasePath() instead of global $base_path.

Changes

  • DomainInterface — added getBasePath() method declaration
  • Domain entity — added getBasePath(); getPath() now includes prefix; setPath() uses request API; setUrl() uses getPathInfo()/getBasePath(); getLink() simplified (no more manual prefix)
  • DomainNavBlock — removed manual prefix addition (now handled by getPath())
  • DomainPathProcessor — uses getBasePath() for base_url option
  • DomainValidator — uses getBasePath() for server response check URL
  • DomainForm — uses getBasePath() for error message URL
  • DomainAccessField — uses getBasePath() for base_url option
  • DomainPrefixTest — added 3 kernel tests for setUrl() with subdirectory base_path + prefix; updated testBasePathExcludesPrefix to verify both methods
  • DomainPathProcessorTest — updated testRewriteWithSubdirectoryInstall to use request server vars instead of global $base_path
mably’s picture

Non-ASCII path prefix support

This patch extends the existing allow_non_ascii setting (used for hostnames and aliases) to also apply to path prefixes. This allows sites using internationalized domain names to also use non-ASCII prefixes like belgië or 日本.

Validation (three-tier)

  1. Config schema — the path_prefix Regex constraint now uses Unicode character classes (\p{L}\p{N}) as a permissive baseline. This catches completely invalid values (slashes, spaces, leading hyphens) during config imports without needing a save.
  2. Form validation — when allow_non_ascii is disabled (default), a stricter ASCII-only pattern ([a-z0-9][a-z0-9_\-]*) is enforced. The HTML5 #pattern attribute is also only added in ASCII mode since it doesn't support \p{} classes.
  3. Entity preSave() — same conditional pattern as form validation, serving as the hard safety net.

Percent-encoding fix

Browsers send non-ASCII path segments as percent-encoded UTF-8 (e.g., belgiëbelgi%C3%AB). Symfony's Request::getPathInfo() returns the encoded form, but the stored prefix is raw UTF-8. Without decoding, prefix matching fails — the negotiator can't find the domain, the inbound processor can't strip the prefix, and setUrl() adds a second copy of the prefix on top of the encoded one.

Fix: rawurldecode() is applied to getPathInfo() in three places before prefix comparison:

  • DomainNegotiator::negotiateByPathPrefix() — decodes path before matching candidates
  • DomainPrefixPathProcessor::processInbound() — decodes path before stripping prefix
  • Domain::setUrl() — decodes path before prefix swap

Performance implication

rawurldecode() is a trivial string operation (single pass, no memory allocation for ASCII-only input since there's nothing to decode). For non-ASCII input, it performs one UTF-8 decode pass on a short string (the path info). This has zero measurable performance impact — it's a few microseconds per request at most. The three call sites only execute when path prefix support is enabled, so sites not using this feature pay nothing.

Note: hostnames don't need this treatment because IDN domains use punycode in DNS (pure ASCII), and Request::getHttpHost() returns the raw ASCII hostname.

UI update

The setting label on the Domain settings page is updated from "Allow non-ASCII characters in domains and aliases" to "Allow non-ASCII characters in domains, aliases, and prefixes".

Test coverage (8 new test methods)

  • testNonAsciiPrefixRejectedByDefault — preSave() throws ConfigValueException when allow_non_ascii is FALSE
  • testNonAsciiPrefixAcceptedWhenEnabled — Unicode prefix saves and persists when enabled
  • testSchemaValidationAcceptsUnicodePrefix — schema Regex accepts CJK characters
  • testSchemaValidationRejectsInvalidPrefix — schema Regex rejects slashes, spaces, leading hyphens
  • testAsciiPrefixStillWorksWhenNonAsciiEnabled — ASCII prefixes unaffected
  • testNonAsciiPrefixNegotiation — negotiator selects correct domain from percent-encoded request path
  • testNonAsciiPrefixInboundProcessing — inbound processor strips both encoded and raw prefixes
  • testNonAsciiPrefixSetUrl — setUrl() produces a single prefix, not a doubled one
kergand’s picture

Path prefix is very good feature. This is the feature what I would use.
Try to test it out.

Nice hob 🙌

mably’s picture

Risks of merging the path prefix feature

1. Experimental flag — The feature is behind a settings checkbox and a DomainServiceProvider that removes all prefix services when disabled. Zero runtime overhead when off. But once sites start using it, removing it becomes a breaking change.

2. Non-ASCII encoding boundary — Non-ASCII prefix matching is handled in a single method (Domain::matchPathPrefix()) which decodes only the first path segment when needed. The fallback is guarded by three checks (container parameter, prefix byte check, percent-encoding presence) and only runs for non-ASCII prefixes on encoded paths. The risk of missed decode sites is low because all prefix comparison goes through this one method.

3. Language negotiation interaction — LanguageNegotiationDomainUrl must strip the domain prefix before the language prefix. The ordering is tested but multilingual + multi-domain + prefix is a complex matrix that may surface edge cases.

4. Contrib module compatibility — Modules that build URLs using $domain->getPath() get the prefix for free. Modules that build URLs manually from getHostname() won't include the prefix. This is a documentation/adoption concern, not a bug.

@idebr, waiting for your suggestions or your RTBC if you think we are ready 😉

idebr’s picture

Status: Needs review » Reviewed & tested by the community
Related issues: +#3578287: Provide upgrade path to Domain path prefix feature

Thanks for your continued work on this issue! I think this is now ready:

Applied the patch to a project running Domain + Country path and performed the following changes:

  1. Replaced url.country with domain in parameters.renderer.config.required_cache_contexts
  2. Enabled the experimental feature 'Enable path prefix support'
  3. Moved the path prefix to the new 'Path prefix' field per domain record
  4. Uninstalled country_path

I opened a follow-up issue in the country_path module to implement these steps as an upgrade path and mark itself obsolete: #3578287: Provide upgrade path to Domain path prefix feature

mably’s picture

Thanks @idebr for your review and valuable feedback.

Point 1 is normally automatically handled by the DomainServiceProvider class.

Let's get this merged.

  • mably committed c9958274 on 3.x
    feat: #3575947 Support a path prefix for Domain records to allow a...
mably’s picture

Status: Reviewed & tested by the community » 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.