Upcasting Custom Route Parameters in Drupal

By Kevin, November 20th, 2023

Thanks to Symfony, Drupal has an incredibly robust routing system. What once used to be a chore in Drupal versions prior to 8 is now a breeze. Creating routes is super simple and parameter upcasting is usually automatic for known entities, like nodes, users, and taxonomy terms.

For example, take this route definition:

entity.comment.canonical:
  path: '/comment/{comment}'
  defaults:
    _title_callback: '\Drupal\comment\Controller\CommentController::commentPermalinkTitle'
    _controller: '\Drupal\comment\Controller\CommentController::commentPermalink'
  requirements:
    _entity_access: 'comment.view'
    comment: \d+

In this case, the CommentController method receives the {comment} parameter, which is automatically upcast to a Comment entity by type hinting it as CommentInterface:

  public function commentPermalinkTitle(CommentInterface $comment) {
    return $this->entityRepository->getTranslationFromContext($comment)->label();
  }

You can see this approach throughout various route definitions in core and contributed modules.

In other cases, we can tell Drupal what to cast the parameter to using route options:

mymodule.dashboard:
  path: '/member/{user}'
  defaults:
    _title: 'Dashboard'
    _controller: '\Drupal\mymodule\Controller\MyAccountController::index'
  requirements:
    _account_access: '\Drupal\mymodule\Access\AccountAccess::access'
  options:
    parameters:
      user:
        type: entity:user

Here, Drupal will check and validate the provided entity id for you. This will ensure that a "404 not found" response is provided if the provided entity ID doesn't exist. If it is found, the request will be processed and a User entity will be provided to the controller method.

What about in cases where a route parameter doesn't map to an entity or known data type at all? Drupal and Symfony can handle that too. You can implement a custom parameter converter by implementing ParamConverterInterface. Let's take a look.

Parameter Conversion

Assume I have an instance where we have a custom module with route definitions. These route definitions contain a unique identifier that lives inside of a third party system. I want to use these identifiers on a per user basis for privileged access to parts of Drupal, similar to gated content or a lightweight portal.

My route definition looks like this:

mymodule.dashboard:
  path: '/member/{remote_object}/dashboard'
  defaults:
    _title: 'My Account'
    _controller: '\Drupal\mymodule\Controller\MyAccountController::dashboard'
  requirements:
    _permission: 'my custom module permission'
  options:
    no_cache: true
    parameters:
      remote_object:
        type: remote_object

Above, remote_object is my route parameter. Barring anything else, the raw value will be passed directly to the controller. Instead, I want that unique ID to be converted to an object and passed along to the controller. Assume the remote system has an API that, given an ID, can return the object record it represents.

In your custom module, update your mymodule.services.yml file to indicate we have a new parameter converter:

services:
  mymodule.param_converter:
    class: Drupal\mymodule\Routing\MyCustomIdParamConverter
    tags:
      - { name: paramconverter }

Remember to add the paramconverter tag.

Under src/Routing, create MyCustomIdParamConverter.php. It will look like the following:

<?php

namespace Drupal\mymodule\Routing;

use Drupal\Core\ParamConverter\ParamConverterInterface;
use Drupal\mymodule\MyModuleCustomValueObject;
use Symfony\Component\Routing\Route;

/**
 * Parameter converter for a custom ID.
 */
class MyCustomIdParamConverter implements ParamConverterInterface {

  /**
   * {@inheritdoc}
   */
  public function applies($definition, $name, Route $route) {
    return isset($definition['type']) && $definition['type'] == 'remote_object';
  }

  /**
   * {@inheritdoc}
   */
  public function convert($value, $definition, $name, array $defaults) {
    // Here you could use an HTTP client to do the API request with $value, which is the parameter value
    // If the request succeeds, process the response body and cache the response object for future requests.
    // Then, return the object or NULL if not located / response failed.
    return ($object_response) ? new MyModuleCustomValueObject($object_response) : NULL;
  }

}

If this request succeeded and a record is found, it will be upcast for the receiving MyAccountController::dashboard controller method:

  /**
   * Builds the response for /member/{remote_object}/dashboard.
   *
   * @return array
   *   The page render array.
   */
  public function dashboard(MyCustomValueObject $remote_object) {
    // build your page response using bits of data from $remote_object
    return $build;
  }

Now our controller method has a fully populated object to work with and there is no need to do your own loading and request within the method, which helps cut down code if you have more than one method. From here I can display information for this user from a remote system which may comprise one or more access protected routes or combinations of IDs.

This is useful for interacting with records and information from disparate systems where they may not have a true representation in Drupal as an Entity record - you can still get the benefit of parameter upcasting if you have the means to populate the parameter.

For more information, see the docs page on parameter conversions in Drupal or route parameters in Symfony.