Drupal 10: Using Parameter Converters To Create Paths For Custom Entities

Drupal's path system gives authors the ability to override the path of an item of content. Whilst the internal path of a page might be "/node/1" the external path can be anything you want, and can be set when editing the item of content.

The addition of the Path Auto module makes this change of path easy for your users by using patterns and automatically generating paths based on the data contained within the content item. You might want to use a path that contains the type of entity being presented, what category it was added to, and even the title of the item of content.

This system creates powerful search engine friendly URLs that can add keywords to the paths that Drupal uses to find content.

When building custom entities there are a few things you need to do in order to add Path Auto functionality. It must contain a 'canonical' link that points to the entity and be a fieldable entity with a field called 'path'. A canonical link always points to the basic path of the content entity, which would be "/node/1" for all content type entities.

Outside of the Path Auto module there are a number of internal classes called parameter converters that implement the core \Drupal\Core\ParamConverter\ParamConverterInterface interface and are used to convert an argument within a path into an entity object. This object is then passed upstream to the form or controller that will be using the entity.

Without the parameter converter in place you would have to accept a value to the form or controller that you would then have to convert into an entity of some kind. This can add a lot more code to the those classes that wouldn't be needed as they need to know how to load the entity and also what to do if the entity doesn't exist.

In this article I will create a content entity and then show how to generate custom paths to that entity in via a custom parameter converter class.

First, let's take a quick look at the ParamConverterInterface to see what methods our classes must implement.

The ParamConverterInterface Interface

The ParamConverterInterface is quite simple as classes that implement this interface only need to have the methods convert() and applies().

convert($value, $definition, $name, array $defaults)

This is used to convert the argument from the path into an object and takes a number of parameters that help with this functionality. This entity object is then returned from the method and if no entity was found then a value of null must be returned, which will be interpreted as a 404 by the upstream code. 

The $value parameter is the value from the path being loaded, so a path of "/node/1" would set the $value to be "1", which we would then use to load the node entity.

The other parameters are set from the options in the route definition for that named parameter and route, which allows you to correctly target the type of entity you want to load and use a default parameter.

applies($definition, $name, Route $route) 

The applies() method is used to determine if this parameter converter should be used for this type of entity. If the parameter converter class is to be used for this parameter then the method returns true.

The $definition parameter is the contents of the options for the current named parameter, with the $name parameter being used to store that name. The $route parameter is the entire route definition from Drupal, as defined in the *.routing.yml file or through a route subscriber.

It's difficult to see what these methods are doing without some context, so let's look at the default EntityConverter class that you can use out of the box in Drupal.

The EntityConverter Class

The \Drupal\Core\ParamConverter\EntityConverter class can be used to convert any route parameter in a Drupal site into an entity object. Since this is used in quite a few places in Drupal it is worth looking into how this works before we go about creating a custom parameter converter class.

The following block of code shows an example route that accepts a content type entity (i.e. node) as a parameter within the path "/entity_converter_example/{node}". This calls the action viewNode() in the ExampleController controller.

entity_converter.example:
  path: '/entity_converter_example/{node}'
  defaults:
    _controller: '\Drupal\entity_converter\Controller\ExampleController::viewNode'
  requirements:
    _permission: 'view content'
  options:
    parameters:
      node:
        type: 'entity:node'

The "{node}" in the path corresponds to the parameter options we attach to the bottom of the route definition. In this case we are telling the route that the "node" parameter has the type of "entity:node". These parameters are passed to the applies() and convert() methods via the $definition parameter.

By using this specific string we tell the EntityConverter class that this parameter is something it needs to resolve as the applies() method watches out for any parameter that has a type that contains the string "entity:". The convert() method then takes the $value and the type of entity being loaded and will attempt to load that entity for us.

The controller action of our route just needs to accept the parameter we attached to the route as a parameter to the action method. The $node parameter we receive is a fully loaded content type object.

  public function viewNode(NodeInterface $node) {
    $output = [];

    $output['label'] = [
      '#markup' => '<h2>' . $node->label() . '</h2>',
    ];

    return $output;
  }

With this in place we can now visit the path "/entity_converter_example/1" and the viewNode() action will receive a fully loaded node object and render the title in a render array.

We can used this same mechanism to pass any entity type to the route, which means we could load a user using the type "entity:user". This can be applied to the route definition by adding the "{user}" parameter to the path and the parameter option for that named path item.

​entity_converter.example:
  path: '/entity_converter_example/{node}/{user}'
  defaults:
    _controller: '\Drupal\entity_converter\Controller\ExampleController::viewNode'
  requirements:
    _permission: 'view content'
  options:
    parameters:
      node:
        type: 'entity:node'
      user:
        type: 'entity:user'
​

All we need to do here is change the action to also include the User entity as a second parameter to the method.

Whilst this mechanism is certainly convenient, it does have a small problem due to the fact that we can only load entities based on their numerical ID. Giving an entity a numeric canonical path can lead to a problem with content enumeration attacks as it gives an attacker the ability to simply copy all of your content by just incrementing through your entities. For example, we could run a simple script on a Drupal site and download page 1 - 100 by just changing the number after the "node" in the path. This isn't a critical problem, but it can lead to unwanted information leakage that has caused some social media companies some embarrassment in the past.

Let's look at how to load custom entities using a custom, non-numeric field.

Creating A Content Entity

The following block of code creates a very simple content entity called "content_entity_example" that we will be using to load via a parameter converter.

<?php

namespace Drupal\paramconverter_example\Entity;

use Drupal\Core\Entity\ContentEntityBase;
use Drupal\Core\Entity\EntityTypeInterface;
use Drupal\Core\Field\BaseFieldDefinition;
use Drupal\Core\StringTranslation\TranslatableMarkup;

/**
 * Concrete class for the Content Entity Example entity.
 *
 * @package Drupal\paramconverter_example\Entity
 *
 * @ContentEntityType(
 *   id = "content_entity_example",
 *   label = @Translation("An Example content Entity"),
 *   base_table = "content_entity_example",
 *   entity_keys = {
 *     "id" = "id",
 *     "label" = "label",
 *     "slug" = "slug",
 *   }
 * )
 */
class ContentEntityExample extends ContentEntityBase implements ContentEntityExampleInterface {

  /**
   * {@inheritDoc}
   */
  public static function baseFieldDefinitions(EntityTypeInterface $entity_type) {
    $fields = [];

    $fields[$entity_type->getKey('id')] = BaseFieldDefinition::create('integer')
      ->setLabel(new TranslatableMarkup('ID'))
      ->setReadOnly(TRUE)
      ->setSetting('unsigned', TRUE);

    $fields['label'] = BaseFieldDefinition::create('string')
      ->setLabel(t('Label'))
      ->setDescription(t('The label of the entity.'));

    $fields['slug'] = BaseFieldDefinition::create('string')
      ->setLabel(t('Slug'))
      ->setDescription(t('The url slug of the entity.'));

    return $fields;
  }

}

Note the addition of the 'slug' field to the field definitions. This will be used to store the name of this entity as a URL safe string that we can use to convert back into the entity object later.

Creating A Custom ParamConverter

Creating the custom ParamConverter involves creating a service definition and a class for that service. In order to be seen as a parameter converter service the service definition of our custom ParamConverter class must also contain a tag of "paramconverter". Adding this tag tells Drupal that it should include this in the list of parameter converters that might be active on the site.

Here is the service definition, which would live in a file called paramconverter_example.services.yml.

services:
  paramconverter_example.content_entity_example:
    class: Drupal\paramconverter_example\ParamConverter\ContentEntityExampleParamConverter
    tags:
      - { name: paramconverter }
    arguments: ["@entity_type.manager"]

The ContentEntityExampleParamConverter class we defined above implements the Drupal\Core\ParamConverter\ParamConverterInterface interface and so must implement the applies() and convert() methods.

Implementing these two methods gives us the class below, which will convert any incoming slug value into a content_entity_example object. Note that in order to do this we also inject the entity_type.manager service into the class so that we can load the entity from the database.

<?php

namespace Drupal\paramconverter_example\ParamConverter;

use Drupal\Core\ParamConverter\ParamConverterInterface;
use Symfony\Component\Routing\Route;
use Drupal\Core\Entity\EntityTypeManagerInterface;

/**
 * A parameter converter class to allow loading of ContentEntityExample entities.
 */
class ContentEntityExampleParamConverter implements ParamConverterInterface {

  /**
   * Entity type manager which performs the upcasting in the end.
   *
   * @var \Drupal\Core\Entity\EntityTypeManagerInterface
   */
  protected $entityTypeManager;

  /**
   * Constructs a new EntityConverter.
   *
   * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
   *   The entity type manager.
   */
  public function __construct(EntityTypeManagerInterface $entity_type_manager) {
    $this->entityTypeManager = $entity_type_manager;
  }

  /**
   * {@inheritDoc}
   */
  public function convert($value, $definition, $name, array $defaults) {
    $contentEntityExampleParamConverterStorage = $this->entityTypeManager->getStorage($definition['type']);

    // Load the entity based on the 'slug' field.
    $results = $contentEntityExampleParamConverterStorage->loadByProperties(['slug' => $value]);
    if (count($results) > 0) {
      return array_pop($results);
    }

    return NULL;
  }

  /**
   * {@inheritDoc}
   */
  public function applies($definition, $name, Route $route) {
    if (!empty($definition['type']) && $definition['type'] === 'content_entity_example') {
      return TRUE;
    }
    return FALSE;
  }

}

This is everything that we need in our parameter converter class.

Using The Custom ParamConverter

To use the ContentEntityExampleParamConverter class we need to add a route definition that has a parameter with the type of "content_entity_example".

paramconverter_example.view:
  path: "/content_entity_example/{content_entity_example}"
  defaults:
    _controller: '\Drupal\paramconverter_example\Controller\ExampleController::viewContentEntityExample'
  requirements:
    _permission: "view content"
  options:
    parameters:
      content_entity_example:
        type: content_entity_example

The applies() method in the ContentEntityExampleParamConverter class will accept the type value and return true, which will in turn cause it to be used to convert the "{content_entity_example}" parameter in the route to a full entity object. The slug parameter is used to load the entity so a numeric value is only accepted if that happens to be the slug value.

This means that we can now go to the path "/content_entity_example/test-content-entity" and load the content_entity_example entity with that slug value.

The viewContentEntityExample action in the ExampleController just needs to accept the $content_entity_example parameter, which will be the fully created content_entity_example entity object. We can then use this object to create a render array that contains the title of the entity.

<?php

namespace Drupal\paramconverter_example\Controller;

use Drupal\Core\Controller\ControllerBase;
use Drupal\paramconverter_example\Entity\ContentEntityExampleInterface;

/**
 * An example controller class.
 */
class ExampleController extends ControllerBase {

  /**
   * Callback for the route paramconverter_example.view.
   *
   * @param \Drupal\paramconverter_example\Entity\ContentEntityExampleInterface $content_entity_example
   *   The Content Entity Example entity.
   *
   * @return array
   *   The render array.
   *
   * @throws \Drupal\Component\Plugin\Exception\InvalidPluginDefinitionException
   * @throws \Drupal\Component\Plugin\Exception\PluginNotFoundException
   */
  public function viewContentEntityExample(ContentEntityExampleInterface $content_entity_example) {
    $output = [];

    $output['label'] = [
      '#markup' => '<h2>' . $content_entity_example->label() . '</h2>',
    ];

    return $output;
  }

}

Assuming that the slug of the entity we create is called "test-content-entity" then the full path to view the entity would be "/content_entity_example/test-content-entity".

If you want to see all of the code presented here in an example module then I have prepared a GitHub repository that contains an example module to demonstrate the ParamConverter seen here. Installing the module will setup a custom entity and create a single instance of that entity so you can try the ParamConverter for yourself.

Note that this example module doesn't take into account entities with the same slug, which is something that might happen during the normal creation of content. If this does occur then the ContentEntityExampleParamConverter class will just return the first entity it finds with this slug. I also don't include any code needed to generate the slug, since there are few mechanisms by which this can be accomplished.

Loading Nodes By Path In A Parameter Converter Example

As a finishing note, I was recently looking to load an entity within a path using a parameter converter. The entity I was loading already had path generation setup, so I wanted to use that information within the route I was using.

The route definition I used was as follows.

entity.mymodule.node:
  path: "/some/path/{node}"
  defaults:
    _controller: '\Drupal\mymodule\Controller\SomeController::someAction'
  requirements:
    _access: 'TRUE'
  options:
    parameters:
      node:
        type: node_slug

The idea that a user could go to the path at "/some/path/some-page" and view the node that was had the path "some-page".

The applies() method just needs to listen for the "node_slug" type being passed to it, at which point it returns true.

  public function applies($definition, $name, Route $route) {
    if (!empty($definition['type']) && $definition['type'] === 'node_slug') {
      return TRUE;
    }
    return FALSE;
  }

The convert() method just needs to look up the node based on the path being send to the route. If the route is found then we can load the original object and return this from the method.

  public function convert($value, $definition, $name, array $defaults) {
    $value = '/' . $value;

    try {
      $params = Url::fromUri("internal:" . $value)->getRouteParameters();
    } catch (\UnexpectedValueException $e) {
      // The URL doesn't match with anything, so return null.
      return NULL;
    }

    $entityType = key($params);
    $node = $this->entityTypeManager->getStorage($entity_type)->load($params[$entityType]);
   
    if ($node instanceof NodeInterface) {
      return $node;
    }

    return NULL;
  }

This approach does have some limitations, namely that the node path cannot contain any slashes or the route injection wouldn't work correctly. It does, however, show how to convert a string in a URL into an object using a parameter converter when the entity in question doesn't have a slug parameter. This technique is enhanced by the path auto module as that allows these paths to be automatically generated.

Conclusion

The parameter converter services are useful when you want to create special paths for your entities that don't use the numerical ID of the entity itself. If you just want to use the numeric ID then the EntityConverter will handle all of your parameter loading for you.

Using this system means that your forms and controllers don't need to have services that aren't needed. The entity object that the upstream code needs is already loaded and ready to use and so you don't need to add extra services to those classes in order for them to load the entity objects first. It also means that if the entity doesn't exist then that is handled before your upstream code is called, meaning that you aren't loading objects into scope only for them to not do anything.

Note that param converters aren't a mechanism for access control, they are only to be used to convert the value into an entity. Once loaded, the entity will also be passed to your access control mechanism as well.

Add new comment

The content of this field is kept private and will not be shown publicly.
CAPTCHA
7 + 1 =
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.