A deep dive into language negotiation and path processing
Recently, we built a custom market-dependent language negotiation for a customer. There is a large ecosystem for market-dependent implementations around the Domain module. We decided to deploy a custom solution since there already existed a lot of downstream logic depending on a given market. Also, we aimed for a tailored, lightweight solution. The development offered a chance to explore some intricacies of language negotiation and path processing in Drupal.
Different code deep dives
There are different approaches to deep dives. The following list is not extensive, but being aware of the goal of a deep dive is essential before engagement.
- Exploration: This loose format is a great tool when learning about a new framework or design pattern. Initially, the objective is unclear, and branching off from an initial thought is desirable. There should be an overarching topic, though. Reading and learning from code written by experienced developers should be in everyone’s routine.
- Comprehension: Understanding an underlying problem can be a burden. There is a chance of getting lost in the depths of the code. Hence, it is imperative to define the objective precisely. It is a very focused task where taking a step back often is advised. If not making process, one should seek help earlier than presumed.
- Reflection: This is the hardest but also the most rewarding task. Usually, it takes place after completing a larger project. Reflecting develops a greater understanding of a topic. There is also the chance to explore some of the missing pieces. One should keep going until one can teach another developer.
In this blog post, we will engage in reflection.
Language types
Usually, when discussing translations in Drupal, the interface, config, and content translation systems are mentioned. For the sake of this article, think of them as the input layer that manages translations. We will focus on the output layer, which depends on the negotiated language. There, the content, interface, and URL language are of importance. We can find the respective definitions in the LanguageInterface
.
// The type of language used to define the content language.
const TYPE_CONTENT = 'language_content';
// The type of language used to select the user interface.
const TYPE_INTERFACE = 'language_interface';
// The type of language used for URLs.
const TYPE_URL = 'language_url';
The interface governs the implementation of the Language objects. These hold the site languages and abstract system languages such as not specified or not applicable. We can obtain the current language from the LanguageManager
service. Note that the method takes an argument for the requested language type. For business logic, one should always ask which type of language is correct for the use case. They may differ by type. Here, we inject the service and get the current interface language.
$this->languageManager->getCurrentLanguage(LanguageInterface::TYPE_INTERFACE);
Theoretically, it is also possible to define additional language types through hook_language_types_info()
.
Language negotiation
Drupal uses the LanguageNegotiator
service to determine the current language. Each language type has a set of negotiation methods defined through plugins that implement the LanguageNegotiatorInterface
. The interface and content language methods are configurable. The settings are in Administration > Configuration > Regional and Language > Languages > Detection and selection and are probably known by most readers. Here, we also get the hint again that the interface and content language may differ.
Contrarily, the language type URL is a fixed negotiation method. By default, it only uses the methods defined by the LanguageNegotiationUrl
and LanguageNegotiationUrlFallback
plugins. If an additional method is required, it can be set in the following hook. Here, the order is respected. The LanguageNegotiationCustom
method should fire first.
function custom_module_language_types_info_alter(array &$language_types) {
array_unshift($language_types[LanguageInterface::TYPE_URL][‘fixed’],
LanguageNegotiationCustom::METHOD_ID);
}
The order of the negotiation methods is crucial, as the first to identify a language determines the result. Tracing down this behavior is more abstract. We can start with the getCurrentLanguage()
method in the ConfigurableLanguageManager
. Here, the negotiators are initialized for the different language types. The initializeType()
method in the LanguageNegotiator
service runs a loop that breaks once the negotiation succeeds. Finally, going one level deeper, the negotiateLanguage()
method calls getLangcode()
on the respective language negotiation plugin. In summary, the language negotiation plugins implement the LanguageNegotiationMethodInterface
that demands the getLangcode()
method. The first valid return of getLangcode()
determines the selected language for the respective type.
Path processors
Some language negotiation methods also implement the InboundPathProcessorInterface
and OutboundPathProcessorInterface
. Path processors are a powerful tool to manipulate the handling of a path associated with an incoming request and an outgoing response. The responsible methods are processInbound()
and processOutbound()
.
We will examine the PathProcessorFront
that maps the empty path to an internal path. Here, we find a straightforward connection between the system settings at Administration > Configuration > System > Basic site settings and the front page.
public function processInbound($path, Request $request) {
if ($path === '/') {
$path = $this->config->get('system.site')->get('page.front');
if (empty($path)) {
throw new NotFoundHttpException();
}
...
}
return $path;
}
If the inbounding path is empty, the method overwrites it with the path found in the system settings. The exception handles the case for an unconfigured front page. The code snippet leaves out some of the handling for the query parameters. Finally, the method returns the processed path. The processing only happens internally. The user does not see the path change in the browser address field. But obviously, the system has to understand which content is associated with the front page.
As a side note, the redirect module handles the reverse behavior that maps an internal to the empty path. The respective logic can be found in the RouteNormalizerRequestSubscriber
.
Next, we will look at the outbound path processing of the AliasPathProcessor
.
public function processOutbound($path, &$options = [], Request $request = NULL, BubbleableMetadata $bubbleable_metadata = NULL) {
if (empty($options['alias'])) {
$langcode = isset($options['language']) ? $options['language']->getId() : NULL;
$path = $this->aliasManager->getAliasByPath($path, $langcode);
...
}
return $path;
}
The method receives a given path, e.g., the canonical URL of a node, as an argument. Then, the AliasManager
service looks up the path alias depending on a given language. The path is overwritten and returned after further massaging. Again, this only establishes the connection between a given path and the respective alias. The user only sees the path after processing is complete.
One of the simple use cases is creating a renderable Link
with a given Url
object. The language option may be set here. But technically, it already defaults to the URL language type.
$url = Url::fromRoute('entity.node.canonical', ['node' => $node_id]);
$language = $this->languageManager->getCurrentLanguage(LanguageInterface::TYPE_URL);
$url->setOption('language', $language);
$link = Link::fromTextAndUrl(new TranslatableMarkup('Front page'), $url);
The path processors allow us to separate logic. In the code snippet above, the path handling happens almost magically when instantiating the URL object. Therefore, it can work in many environments, e.g., different language negotiation setups.
Note that the only responsibility of the path processors is to make the path consumable or deliverable. It is not the place to add extensive business logic. For that use case, an event subscriber might be more suitable.
Path processor manager and tagged services
The core PathProcessorManager
service executes the path processor sequentially ordered by priority. It also implements the InboundPathProcessorInterface
and OutboundPathProcessorInterface
. Therefore, we can understand it as a collector for all defined path processors. Indeed, it is a service collector, as seen from the service definition.
path_processor_manager:
class: Drupal\Core\PathProcessor\PathProcessorManager
tags:
- { name: service_collector, tag: path_processor_inbound, call: addInbound }
- { name: service_collector, tag: path_processor_outbound, call: addOutbound }
The respective tag appears for all defined tagged path processor services, e.g., for the previously discussed PathProcessorFront
or AliasPathProcessor
. The priority in the service definition establishes the order of these processors.
The attentive reader will have noticed that the language negotiation methods are plugins, though. Technically, these are invoked by the PathProcessorLanguage
service, which the LanguageServiceProvider
dynamically registers. Interestingly, the initProcessors()
method sorts the language negotiation methods by the weight defined in the annotation. That might be against the expectation that the path processors fire in the order configured in the admin interface.
Conclusion and further reading
This article taught us about the three different language types and how language negotiation methods negotiate them. Some of them are configurable, whereas another is fixed. Further, language negotiation methods can also act as path processors. Since language negotiation methods are plugins, they are dynamically invoked by the language path processor service, which in turn is collected, besides other services, by the path processor manager.
This article was last modified on June 16, 2024.
I am always open for feedback or further questions. Feel free to contact me.
Back to overview