Creating A Language Cascade In Drupal 8

From the pages of 'crazy things you can do with Drupal 8' comes this idea that I recently had to implement on a site. This was on a site that had implemented translations, but they needed something a little extra. The central idea was that if you visited a page on the site in a particular language, and that language didn't exist, then to try and give the user a similar language.

If you weren't already aware, Drupal's default behaviour is to return the default language version of a page if the language requested doesn't exist. If the Path Auto module is installed (which is usually the case on most Drupal sites) then Drupal will not be able to translate the path alias correctly and will issue a 404 if the requested translation doesn't exist. Drupal can't take the path for the French page /fr/about-us and find the original node because as far as it's concerned /fr/about-us doesn't exist.

This requirement therefore needed to alter the way in which Drupal 8 finds and retrieves translations. Doing this led me to learn a few things about the inner workings of Drupal 8.

The LanguageSwapper Service

Before getting into that, I needed a way of deciding what language to select. I hinted before at picking a similar language, but a central mechanism is needed to facilitate this. So I created a Drupal service called LanguageSwapper. This service was used to change one language into another. The central idea of this service was to take a language string like 'en-gb' and to extract a language string from it.

First we define the language swapper service in a custom module services.yml file.

services:
  language_cascade.language_swapper:
    class: Drupal\language_cascade\LanguageSwapper
    arguments: ['@language_manager']

The central method of this class is the swapLanguage() method. This takes a language code and splits it using the '-' character. The following decisions are then made.

  • If the string contains more than 1 part, snip the last part of the language.
  • If the string does not contain a '-' character then just return the language string that was passed to it.

In all instances of creating a new language we first test to ensure that the language we extract actually exists on the site. This prevents us from causing upstream errors by trying to get translations of languages that don't exist.

Here is the swapLanguage() method in full.

  /**
   * Convert the language code to something else.
   *
   * @param string $langcode
   *   (optional) The langcode to swap.
   *
   * @return string
   *   The new langcode if found, or the existing langcode if not.
   */
  public function swapLanguage($langcode = NULL) {
    $langcode = $langcode ?: $this->languageManager->getCurrentLanguage(LanguageInterface::TYPE_URL)->getId();

    // Split the language into parts.
    $languageParts = explode('-', $langcode);

    $numberOfLanguageParts = count($languageParts);

    if ($numberOfLanguageParts > 1) {
      // If we have found three parts to the language string then snip off the
      // end element and create a new language string. This will convert
      // strings like zh-hant-tw into zh-hant.
      $newLangcode = implode('-', array_slice($languageParts, 0, $numberOfLanguageParts - 1));

      if ($this->languageManager->getLanguage($newLangcode)) {
        // We have found a viable language, so return that.
        return $newLangcode;
      }

      // Snip off the end of the array.
      unset($languageParts[$numberOfLanguageParts - 1]);
    }

    return $langcode;
  }

Here are some examples to demonstrate what the language swapper will do.

Input LanguageInput Language CodeOutput LanguageOutput Language Code
Simplified Chinesezh-hansChinesezh
Taiwanesezh-hans-twSimplified Chinesezh-hans
Canadian Frenchfr-caFrenchfr
FrenchfrFrenchfr
British Englishen-gbEnglishen

This is the selection mechanism that is used to pick which language to display to the user when they request a non-existent translation of a page. For example, if the user visits the site in fr-ca then they will see the site in French. The upshot of this is that the translation only has to be done once and all regions that speak a language are covered without having to copy and paste content all over the place. This also means that the admins of the site don't need to translate everything when they need to create a page in a particular language.

Overriding Drupal Core

The LanguageSwapper is only the start of this process. In order to get everything working we also need to override the way in which Drupal loads and serves a page of content.

To explain, the usual process in Drupal (with Path module enabled) is for three classes to work together to serve the right page at the right time. This means that in order to change the way in which Drupal serves the page we need to alter these three core classes. This is done in a way that is as respectful as possible to the existing flow, whilst adding our own functionality on top. The three classes, and their actions, are described below.

  • Drupal\Core\Path\AliasManager - Used by Drupal to manage aliases and is used to find and retrieve aliases from the database.
  • Drupal\Core\PathProcessor\PathProcessorAlias - This is used by Drupal to convert an aliased path to a usable route. So this takes a path like 'node/123' and allows it to have the alias of 'my-age'.
  • Drupal\Core\ParamConverter\EntityConverter - This class is where entities (i.e. nodes) are loaded within the system and also allows entities of a particular language to be loaded.

Overriding PathProcessorAlias

The default Drupal class of PathProcessorAlias is used to allow paths in a Drupal site to be something other than they normally would be. For example, nodes on a Drupal site would normally be called /node/x, where x is the ID of the node. With PathProcessorAlias we can change that to any other value we want and Drupal will map it back to the original node.

The language gets added to the start of this path so if we looked at an English node the path would be /en/node/123. Although the language we want to use exists, the translation itself might not exist. So if we were trying to load /ca-fr/node/123 then we need to inform Drupal that the node does exist. We therefore need to override this class so that Drupal can successfully locate the translation to the correct node.

Overriding the PathProcessorAlias can be done using a custom service definition that defines a couple of tags. By stipulating the path_processor_outbound tag here we are telling Drupal that this class is part of that service. We are giving it a slightly higher priority over the default of 300 so that it will get used over the default.

  path_processor_alias:
    class: Drupal\language_cascade\PathProcessor\LanguageCascadePathProcessorAlias
    tags:
      - { name: path_processor_outbound, priority: 301 }
    arguments: ['@path.alias_manager', '@language_cascade.language_swapper']

Within in our LanguagecascadePathProcessorAlias class we override the processOutbound method. If we receive a path that looks like /node/123 from the parent processOutbound method then that gives us a signal that we need to further load the path. This is done by using the LanguageSwapper service to select the right language and then trying to load that translation from within Drupal.

Here is the class I'm using to override PathProcessorAlias.

<?php

namespace Drupal\language_cascade\PathProcessor;

use Drupal\Core\Path\AliasManagerInterface;
use Drupal\Core\Render\BubbleableMetadata;
use Symfony\Component\HttpFoundation\Request;
use Drupal\Core\PathProcessor\PathProcessorAlias;
use Drupal\language_cascade\LanguageSwapper;

/**
 * Processes the inbound path using path alias lookups.
 */
class LanguageCascadePathProcessorAlias extends PathProcessorAlias {

  /**
   * The language swapper.
   *
   * @var \Drupal\language_cascade\LanguageSwapper|null
   */
  protected $languageSwapper;

  /**
   * Constructs a PathProcessorAlias object.
   *
   * @param \Drupal\Core\Path\AliasManagerInterface $alias_manager
   *   An alias manager for looking up the system path.
   * @param \Drupal\language_cascade\LanguageSwapper|null $languageSwapper
   *   (optional) The language swapper.
   */
  public function __construct(AliasManagerInterface $alias_manager, LanguageSwapper $languageSwapper = NULL) {
    $this->aliasManager = $alias_manager;
    $this->languageSwapper = $languageSwapper;
  }

  public function processOutbound($path, &$options = [], Request $request = NULL, BubbleableMetadata $bubbleable_metadata = NULL) {
    $path = parent::processOutbound($path,$options, $request, $bubbleable_metadata );

    $langcode = isset($options['language']) ? $options['language']->getId() : NULL;

    if (preg_match('/node\/\d+$/', $path, $output_array) && !$langcode) {
      // If a 'node/x' path is returned then try to find the real alias. This
      // will be the root path in combination with the next level path.
      $newLangcode = $this->languageSwapper->swapLanguage($langcode);
      $newPath = $this->aliasManager->getAliasByPath($path, $newLangcode);
      if ($newPath != $path) {
        $path = $newPath;
      }
    }

    return $path;
  }

}

Overriding AliasManager

The AliasManager class is used by Drupal to load aliases from the database. This actually handles a very similar task to PathProcessorAlias, but this class is ultimately where a page not found response comes from. As a result, we need to ensure that if the service hasn't found the correct page then to use the LanguageSwapper service to load the correct translation and reset the page not found response.

As the AliasManager is not defined with any tags that means we need to use another method to override this class. This is where we need to use a ServiceProvider to intercept and alter the services as they are loaded into the system. What I am doing here is loading the path.alias_manager service and replacing that class with our own custom class. In addition to this I am also providing an additional argument in the form of the LanguageSwapper service so that we have access to that service within our custom class.

<?php

namespace Drupal\language_cascade;

use Drupal\Core\DependencyInjection\ContainerBuilder;
use Drupal\Core\DependencyInjection\ServiceProviderBase;
use Symfony\Component\DependencyInjection\Reference;

/**
 * Class LanguageCascadeServiceProvider.
 *
 * @package Drupal\language_cascade
 */
class LanguageCascadeServiceProvider extends ServiceProviderBase {

  /**
   * {@inheritdoc}
   */
  public function alter(ContainerBuilder $container) {
    // Override the alias manager with our own class.
    $definition = $container->getDefinition('path.alias_manager');
    $definition->setClass('Drupal\language_cascade\Path\LanguageCascadeAliasManager');

    // Add the language swapper as a dependency to this new service.
    $definition->addArgument(new Reference('language_cascade.language_swapper'));
  }

}

With that in place we can now add our own alias manager override class. This class runs after Drupal's AliasManager object and will attempt to correct the page not found response. If the path was not found then the 'noPath' array will contain information about the language and path that was not found. What we need to do is use the LanguageSwapper service to see if a path exists of a different language and return the path of that language.

<?php

namespace Drupal\language_cascade\Path;

use Drupal\Core\Cache\CacheBackendInterface;
use Drupal\Core\Language\LanguageInterface;
use Drupal\Core\Language\LanguageManagerInterface;
use Drupal\Core\Path\AliasManager;
use Drupal\Core\Path\AliasStorageInterface;
use Drupal\Core\Path\AliasWhitelistInterface;
use Drupal\language_cascade\LanguageSwapper;

/**
 * The default alias manager implementation.
 */
class LanguageCascadeAliasManager extends AliasManager {

  /**
   * The language swapper.
   *
   * @var \Drupal\language_cascade\LanguageSwapper|null
   */
  protected $languageSwapper;

  /**
   * Constructs an AliasManager.
   *
   * @param \Drupal\Core\Path\AliasStorageInterface $storage
   *   The alias storage service.
   * @param \Drupal\Core\Path\AliasWhitelistInterface $whitelist
   *   The whitelist implementation to use.
   * @param \Drupal\Core\Language\LanguageManagerInterface $language_manager
   *   The language manager.
   * @param \Drupal\Core\Cache\CacheBackendInterface $cache
   *   Cache backend.
   * @param \Drupal\language_cascade\LanguageSwapper|null $languageSwapper
   *   (optional) The language swapper.
   */
  public function __construct(AliasStorageInterface $storage, AliasWhitelistInterface $whitelist, LanguageManagerInterface $language_manager, CacheBackendInterface $cache, LanguageSwapper $languageSwapper = NULL) {
    $this->storage = $storage;
    $this->languageManager = $language_manager;
    $this->whitelist = $whitelist;
    $this->cache = $cache;
    $this->languageSwapper = $languageSwapper;
  }

  /**
   * {@inheritdoc}
   */
  public function getPathByAlias($alias, $langcode = NULL) {
    $newAlias = parent::getPathByAlias($alias, $langcode);

    if (is_null($langcode)) {
      // Ensure that the language has been set so that we can see if the noPath
      // array has been filled for the current language.
      $langcode = $langcode ?: $this->languageManager->getCurrentLanguage(LanguageInterface::TYPE_URL)->getId();
    }

    if (isset($this->noPath[$langcode][$alias])) {
      // Path not found.
      // If the path was not found for this language then snip off the end and
      // try to find that language instead.
      $newLangcode = $this->languageSwapper->swapLanguage($langcode);
      if ($langcode != $newLangcode && $path = $this->storage->lookupPathSource($alias, $newLangcode)) {
        // Add the found alias to the lookupMap array.
        $this->lookupMap[$newLangcode][$path] = $alias;
        // Now that we have found the alias remove it from the noPath array.
        unset($this->noPath[$langcode][$alias]);
        return $path;
      }
    }

    return $newAlias;
  }

}

Overriding EntityConverter

Finally, with the path aliasing sorted out, the next step is to ensure that that the correct page of content is loaded into place. The class EntityConverter has the responsibility of loading the correct entity based on language and the ID of the entity. As this service is defined using tags we can use these tags to override the service and give it a higher priority, much like we did with PathProcessorAlias.

  language_cascade.language_entity_converter:
    class: Drupal\language_cascade\ParamConverter\LanguageCascadeEntityConverter
    tags:
      - { name: paramconverter, priority: 9 }
    arguments: ['@entity.manager', '@language_manager', '@language_cascade.language_swapper']

When the EntityConverter runs we receive a an object. In order to ensure that we are loading the correct object we perform a couple of checks. First we make sure that the entity exists and that the correct language variant is loaded. If not then we need to use the LanguageSwapper object to swap the language and then load the correct translation for that language.

Here is our custom LanguageCascadeEntityConverter class.

<?php

namespace Drupal\language_cascade\ParamConverter;

use Drupal\Core\Entity\EntityInterface;
use Drupal\Core\Entity\EntityManagerInterface;
use Drupal\Core\Language\LanguageInterface;
use Drupal\Core\Language\LanguageManagerInterface;
use Drupal\Core\TypedData\TranslatableInterface;
use Drupal\Core\ParamConverter\EntityConverter;
use Drupal\language_cascade\LanguageSwapper;

/**
 * Class LanguageCascadeEntityConverter.
 *
 * Overrides the default Drupal\Core\ParamConverter\EntityConverter class in
 * order to allow us to swap the displayed languages of entities.
 *
 * @package Drupal\language_cascade\ParamConverter
 */
class LanguageCascadeEntityConverter extends EntityConverter {

  /**
   * The language swapper.
   *
   * @var \Drupal\language_cascade\LanguageSwapper|null
   */
  protected $languageSwapper;

  /**
   * Constructs a new EntityConverter.
   *
   * @param \Drupal\Core\Entity\EntityManagerInterface $entity_manager
   *   The entity manager.
   * @param \Drupal\Core\Language\LanguageManagerInterface|null $language_manager
   *   (optional) The language manager. Defaults to none.
   * @param \Drupal\language_cascade\LanguageSwapper|null $languageSwapper
   *   (optional) The language swapper.
   */
  public function __construct(EntityManagerInterface $entity_manager, LanguageManagerInterface $language_manager = NULL, LanguageSwapper $languageSwapper = NULL) {
    $this->entityManager = $entity_manager;
    $this->languageManager = $language_manager;
    $this->languageSwapper = $languageSwapper;
  }

  /**
   * {@inheritdoc}
   */
  public function convert($value, $definition, $name, array $defaults) {
    $entity = parent::convert($value, $definition, $name, $defaults);

    // If the entity type is translatable, ensure we return the proper
    // translation object for the current context.
    if ($entity instanceof EntityInterface && $entity instanceof TranslatableInterface) {
      $langcode = $this->languageManager()
        ->getCurrentLanguage(LanguageInterface::TYPE_URL)
        ->getId();

      // If the language of the translated item:
      // - Does not equal the current language.
      // - Is the site default language.
      // Then attempt to find the next best translation available.
      if ($entity->language()->getId() != $langcode && $entity->language()->getId() == $entity->getUntranslated()->language()->getId()) {

        $newLangcode = $this->languageSwapper->swapLanguage($langcode);
        if ($langcode != $newLangcode) {
          // If the new langcode we found is different then attempt to find the
          // translation corresponding to the new langcode.
          try {
            $translation = $entity->getTranslation($newLangcode);
            if ($translation) {
              // If a translation was found then make this the current entity.
              $entity = $translation;
            }
          }
          catch (\Exception $e) {
            // Do nothing, this is an actual 404 page.
          }
        }
      }
    }

    return $entity;
  }

}

With all that in place the language cascade is fully working. If we create a page of content in French and have a language setup for Canadian French then loading the Canadian French version of the page will load the French version instead.

Next Steps?

The code above is by no means complete. There are a few improvements or alterations that I could do to make things work a little better.

This actually gets close to the "don't hack core" rule. With modern dependency injected systems like means that it's possible to alter core classes without actually changing them. The prototype of the above code wasn't that friendly to Drupal and just overwrote class methods instead of calling them first and then looking at the outcome. I spent a lot of time and effort making sure that this wouldn't cause any problems with future updates to Drupal core and that it didn't interfere with existing functionality of Drupal.

When writing this language cascade I did originally look at plugins like the content language negotiation system. This would have been preferable from overwriting core classes. I'm pretty sure that the end result would not have been correct. The language negotiation system is more about redirecting the user to the correct version of the page and not about showing them the correct content.

One problem spotted during development was that setting the paramconverter tag (as used in the EntityConverter classes) to a high value caused Views UI to crash. This was because it was trying to use my EntityConverter instead of a custom Views UI version to load Views. The current values allow Drupal to function normally whilst also providing the functionality I needed.

Further improvements I could do would be to allow the language cascade to fall into more than one language. As it stands the functionality above will only drop into the next available language. With a little more work it should be possible to allow the cascade to more than one language or perhaps load the default site language if no other language was found during the cascade.

The LanguageSwapper service could also integrate with some configuration to provide a better mechanism to cascade the language. This would mean adding configuration pages or even integrating into the language weight system that Drupal uses.

As a minimum this service does the job without any user interaction at all, which covers the original requirements. If you are reading this and notice that I've missed something obvious or could have done this with a simple plugin then please let me know. I'd love to hear from you.

Comments

Hi,

Thanks a lot for detailed post. I have the similar requirement and if possible can we have the folder structure and filenames for the above shared code, unable to get it and please share the same.

Thanks,

Ravi

 

 

Permalink

I wasn't going to release this as a module. It's pretty custom and has some problems. Not every entity is covered by the cascade and you'll end up writing lots of code to add things like menus and blocks into the mix.

There should be enough information in the example code blocks above to understand the namespaces and therefore the directory stricture of the system. I can put the code on github if you think it will help?

Name
Philip Norton
Permalink

Thanks a lot for the prompt response and can you put the code on github and it will help and i will use the same code to extend the my requirements. Thanks again.

Permalink

I have created a git repo that contains the Language Cascade code.

https://github.com/hashbangcode/language_cascade

I've made a few changes and improvements since the above post was created. Let me know how you get on, I'd be interested to see your feedback.

Name
Philip Norton
Permalink

Add new comment

The content of this field is kept private and will not be shown publicly.
CAPTCHA
2 + 5 =
Solve this simple math problem and enter the result. E.g. for 1+3, enter 4.
This question is for testing whether or not you are a human visitor and to prevent automated spam submissions.