Problem/Motivation

The Domain Source module has a path processor that allows \Drupal\Core\Url objects to specify the target domain. This is a very useful feature, but is currently aimed to support a specific use case: force the outbound path to the Domain Source.

Proposed resolution

Allow Url objects to specify the Domain as an option:

  1. Move the path processor logic from domain_source to domain
  2. domain_source sets a Url option instead of rewriting the path

Remaining tasks

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

User interface changes

None

API changes

Url objects can now specify the Domain as an option

Data model changes

None

Issue fork domain-3574800

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)

Hi @idebr, can you give a few use cases (besides source domain) for this feature?

idebr’s picture

A project I am working on uses the domain option for Url objects for the following purposes:

Content is assigned to multiple domains, but the canonical URL is set to the Domain source URL to prevent duplicate content in search engines:

/**
 * Implements hook_ENTITY_TYPE_view_alter() for node.
 */
function custom_module_node_view_alter(array &$build, EntityInterface $entity, EntityViewDisplayInterface $display) {
  assert($entity instanceof NodeInterface);
  if (empty($build['#attached']['html_head_link'])) {
    return;
  }
  foreach ($build['#attached']['html_head_link'] as &$item) {
    if (isset($item[0]['rel']) && $item[0]['rel'] === 'canonical') {
      $item[0]['href'] = $entity->toUrl('canonical', ['domain' => $entity->get(DomainSourceElementManagerInterface::DOMAIN_SOURCE_FIELD)->entity])->setAbsolute()->toString();
    }
  }
}

Search results include results from other domains, but link to the Domain Source if not available on the current domain:


/**
 * Implements hook_preprocess_HOOK().
 */
function custom_module_preprocess_node__search_result(array &$variables) {
  /** @var \Drupal\domain\DomainInterface $activeDomain */
  $activeDomain = \Drupal::service('domain.negotiator')->getActiveDomain();

  /** @var \Drupal\node\NodeInterface $node */
  $node = $variables['elements']['#node'];

  // Link all search results to the primary domain.
  if ($node->hasField('field_domain_source')) {
    $domainSource = $node->get('field_domain_source')->target_id;
    if ($activeDomain->id() !== $domainSource) {
      $domain = $node->get('field_domain_source')->entity;
      if (NULL !== $domain) {
        $variables['url'] = $node->toUrl('canonical', [
          'domain' => $domain,
        ])->toString();
      }
    }
  }
}

Clickable cards have an additional overlay link:

$link = $entity->toLink();
    $active_domain = $this->domainNegotiator->getActiveDomain();

    // If the active domain is not among the domains attached to the entity,
    // use the primary domain.
    if (
      $active_domain instanceof DomainInterface &&
      $active_domain->id() === 'organisation' &&
      $entity->hasField('field_domain_access') &&
      (!$entity->hasField('field_domain_all_affiliates') ||
        (bool) $entity->get('field_domain_all_affiliates')->value === FALSE)
    ) {
      $referenced_domains = $entity->get('field_domain_access')->referencedEntities();

      $active_domain_among_linked_domains = FALSE;
      foreach ($referenced_domains as $referenced_domain) {
        if ($referenced_domain instanceof DomainInterface && $referenced_domain->id() === $active_domain->id()) {
          $active_domain_among_linked_domains = TRUE;
          break;
        }
      }

      if (
        $active_domain_among_linked_domains === FALSE &&
        $entity->hasField('field_domain_source') &&
        $entity->get('field_domain_source')->entity instanceof DomainInterface
      ) {
        $link->getUrl()->setOption('domain', $entity->get('field_domain_source')->entity);
      }
mably’s picture

Isn't domain_source supposed to do that automatically? 🤔

idebr’s picture

Rewriting the canonical is disabled for this project. Reading content from different Domain sources would result in switching domains, so visitors would lose their orientation.

mably’s picture

Analysis

The DomainSourcePathProcessor already supports an explicit domain_target_id URL option that bypasses the entity field lookup. So you can already do:

$url->setOption('domain_target_id', 'example_com');

But this only works when domain_source is installed, since the path processor lives entirely in that submodule. The base domain module has no outbound path processor at all.

The core rewriting logic in DomainSourcePathProcessor is just two lines:

$options['base_url'] = $source_domain->getPath();

$options['absolute'] = TRUE;

The proposal would split the 664-line processor into two concerns:

  1. Base domain module — a lightweight outbound path processor that reads a domain option from the URL and rewrites base_url + absolute. No entity awareness, no route matching — just "if a domain is specified, use it."
  2. domain_source module — keeps all the entity/route/field logic, but instead of rewriting the URL itself, it sets $options['domain'] and lets the base processor handle the rewrite.

This would allow any custom code to target a specific domain on a Url object without needing domain_source enabled — covering the use cases described (canonical URLs, cross-domain search links, card overlays).

Considerations

  • The domain_source processor also handles language negotiation re-processing, destination parameter rewriting, and alter hooks (hook_domain_source, hook_domain_source_path). Those would stay in domain_source.
  • Processor priority ordering matters — the base domain processor would need to run after domain_source sets the option but before the final URL is built.
  • The existing domain_target_id option could be kept for BC, with the new domain option accepting either a domain entity or a machine name.

mably’s picture

Status: Postponed (maintainer needs more info) » Needs review

Summary

Added a lightweight outbound path processor to the base domain module, enabling any code to target a specific domain on a Url object without installing domain_source:

$url->setOption('domain', 'example_com');
// or
$url->setOption('domain', $domainEntity);

Commit 1: feat: #3574800 Allow Url objects to specify the Domain as an option

New file domain/src/HttpKernel/DomainPathProcessor.php implements OutboundPathProcessorInterface:

  • Checks for a domain URL option
  • Accepts either a DomainInterface entity or machine name string
  • Sets $options['base_url'] and $options['absolute'] = TRUE when valid
  • Adds the domain entity as a cacheable dependency (cache tag invalidation when the domain changes)
  • Skips external URLs and gracefully handles invalid/missing domain values
  • Single dependency: EntityTypeManagerInterface (autowired)

New kernel test file domain/tests/src/Kernel/DomainPathProcessorTest.php with 7 test methods covering:

  • Rewrite with string machine name
  • Rewrite with DomainInterface entity
  • No-op when domain option is absent
  • External URL skipped
  • Invalid domain string handled gracefully
  • Invalid option type handled gracefully
  • NULL bubbleable metadata handled

Modified domain/domain.services.yml registers the service as domain.path_processor with path_processor_outbound tag at priority 80 — runs after domain_source (90).

DomainSourcePathProcessor was not changed in this commit.

mably’s picture

Commit 2: Delegate URL rewriting from DomainSourcePathProcessor to DomainPathProcessor

DomainSourcePathProcessor (priority 90) no longer rewrites base_url/absolute itself. Instead, when a source domain differs from the active domain, it sets $options['domain'] = $source and lets DomainPathProcessor (priority 80) handle the actual URL rewriting.

Changes in DomainSourcePathProcessor::processOutbound():

  • Early return if $options['domain'] is already set — avoids double-processing.
  • Replaced $options['base_url'] = rtrim($source->getPath(), '/') + $options['absolute'] = TRUE + addCacheContexts(['url.site']) with $options['domain'] = $source.

Commit 3: Move cross-domain URL rewriting features to DomainPathProcessor

Language negotiation re-processing and destination parameter handling are not domain_source-specific — they apply to any cross-domain URL rewrite. Moving them into DomainPathProcessor ensures any code setting $options['domain'] automatically gets these features.

DomainPathProcessor (domain module)

  • Added dependencies: ConfigFactoryInterface, DomainNegotiationContext, ?LanguageNegotiatorInterface
  • Added methods: isLanguageNegotiationEnabled(), isSameLanguageNegotiationConfig(), processLanguageNegotiationOutbound(), isDestinationDomainEnabled(), processDestinationParameter(), reset()
  • processOutbound() now runs cross-domain features (language negotiation + destination parameter) after resolving the domain and before setting base_url/absolute
  • Refined cache metadata: addCacheableDependency($domain) is added unconditionally (cache tag invalidation when the domain entity changes); url.site cache context is added only when destination parameter processing is active (the only output that includes the current domain's host)

DomainSourcePathProcessor (domain_source module)

  • Removed the $languageNegotiator constructor parameter and LanguageNegotiatorInterface import
  • Removed all four cached properties and five methods related to language negotiation and destination parameter handling
  • Simplified reset() — no longer clears language-related caches

Settings migration

  • Settings moved from domain_source.settings to domain.settings:
    • source_language_negotiation → language_negotiation
    • allow_destination_domain → allow_destination_domain
  • Config schema and install defaults updated in both modules
  • Form elements moved from DomainSourceSettingsForm to DomainSettingsForm, wrapped in a closed-by-default "Experimental features" details group
  • Labels updated: "source domain" → "target domain"

Update hooks

  • domain_update_10002() — Initializes language_negotiation and allow_destination_domain in domain.settings (defaults to FALSE)
  • domain_source_update_10004() — Migrates values from domain_source.settings to domain.settings and clears the old keys

Tests

  • DomainSourceLanguageNegotiationTest: config references updated to domain.settings:language_negotiation; clearCaches() now also resets domain.path_processor
  • DomainSourceDestinationKernelTest: config and service references updated to domain.settings / domain.path_processor
mably’s picture

Documentation and DomainSourceToken Simplification

Two new documentation commits added:

1. Documentation pages (docs/)

New page docs/domain/index.md documents the Domain module's cross-domain URL rewriting:

  • The $options['domain'] URL option usage with machine name string or DomainInterface entity
  • How DomainPathProcessor::processOutbound() resolves the domain, applies features, rewrites URLs, and adds cache metadata
  • Integration with Domain Source — DomainSourcePathProcessor sets $options['domain'] and delegates
  • Experimental features documentation:
    • Language negotiation for cross-domain URLs — re-runs LanguageNegotiationUrl in the target domain's context when configs differ
    • Domain-scoped destination redirects — adds destination_domain query parameter for cross-domain redirect accuracy

Updated page docs/domain_source/index.md:

  • Simplified to describe delegation via $options['domain']
  • New Token section documents [node:canonical-source-domain-url] token — useful when canonical route is added to Excluded entity route suffixes
  • Cross-domain features now reference Domain module documentation

mkdocs.yml — Added the "Domain" nav entry.

2. Simplify DomainSourceToken

Refactored to use $options['domain'] mechanism instead of getExcludedRoutes() logic.

Before: The token checked if canonical was in excluded routes. If excluded, it manually built the URL via Domain::buildUrl(). Otherwise, it called toUrl()->setAbsolute()->toString() and relied on the path processor.

After: The token sets $url->setOption('domain', $source) on the Url object and lets DomainPathProcessor handle rewriting. This works regardless of exclude_routes configuration — when DomainSourcePathProcessor encounters a URL with $options['domain'] already set, it returns immediately.

Uses $url->setAbsolute()->toString(TRUE) to get a GeneratedUrl object (which extends BubbleableMetadata) instead of a plain string. This captures all cache metadata from the URL generation chain — including the domain entity's cache tags added by DomainPathProcessor. The URL string is extracted via getGeneratedUrl() and all cache metadata is merged into the token's $bubbleable_metadata via merge().

Removed:

  • ConfigFactoryInterface dependency
  • $excludedRoutes property
  • getExcludedRoutes() method

The DomainSourceTokenTest passes (1 test, 8 assertions).

mably’s picture

BC Breaking Changes Analysis

Analysis of the full changeset from #3574800 (5 commits) evaluated as a single before/after comparison.

Medium

1. Public API removed from DomainSourceToken

  • Public property $excludedRoutes — removed
  • Public method getExcludedRoutes() — removed
  • Constructor __construct(ConfigFactoryInterface) — removed entirely

Impact: Any code accessing the property, calling the method, or instantiating/subclassing DomainSourceToken will break.

Low

2. Constructor changes in DomainSourcePathProcessor

  • ?LanguageNegotiatorInterface $languageNegotiator (last parameter) — removed
  • $pathMatcher and $sourceHelper — swapped ($sourceHelper is now before $pathMatcher, positioning $pathMatcher last for easier future removal)

Impact: Only affects subclasses calling parent::__construct() with positional arguments; normal DI autowiring is unaffected.

Summary

Severity # Description
Medium 1 Public API removed from DomainSourceToken
Low 2 Constructor changes in DomainSourcePathProcessor
idebr’s picture

Status: Needs review » Reviewed & tested by the community

Wow, this is looking great! Thanks for taking up this issue.

A minor remark which I'll leave to you to decide: I would suggest the Url domain option is required to be Domain object similar to language: https://git.drupalcode.org/project/drupal/-/blob/main/core/lib/Drupal/Co...
The Domain entity is typically autoloaded when working with entity reference fields anyway, and making the option take two different data types complicates code analysis.

Also I noticed there is currently no way to get domain path alias processing to apply to the URL object, since this is locked behind the domain_source logic. This is better fixed in the domain_path module by overwriting the \Drupal\path_alias\PathProcessor\AliasPathProcessor. I'll open an issue in the domain_path issue queue as a follow up.

  • mably committed 358cd168 on 3.x
    feat: #3574800 Allow Url objects to specify the Domain as an option
    
    By...
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.

idebr’s picture

Status: Fixed » Closed (fixed)

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