Drupal 8 controller callback argument resolving explained

Thumbnail

Drupal 8 has a robust routing system built on top of Symfony components. Robust can be taken in many ways. It's powerful and yet magical, making it confusing at times. There is a lot of great documentation on Drupal.org which covers the routing system, route parameters, and highlights of the underlying functionality. But the magic isn't fully covered.

If you are not familiar with the Drupal 8 routing system, head over to the docs and dive in. I'm also assuming you have written routes using parameters and have experienced the magic of parameter conversions. If you haven't, again head over to the docs and read up on using parameters in routes

I don't want to dive completely into how routes work with ParamConverter services to do parameter upcasting. I want to cover more on how Drupal knows to pass proper arguments to your controller method. Parameter upcasting topic is covered fairly well in the documentation on parameter upcasting in routes

I want to discuss how the controller's callback arguments are resolved and put into proper order in our method. For example, how I can take this custom 404-page route I created for ContribKanban.com

contribkanban_pages.not_found:
  path: '/not-found'
  defaults:
    _controller: '\Drupal\contribkanban_pages\Controller\PagesController::on404'
    _title: 'Resource not found'
  requirements:
    _access: 'TRUE'

And have a method which knows the exception that was thrown, the request and route match.

class PagesController extends ControllerBase {
  public function on404(\Exception $exception, Request $request, RouteMatchInterface $match) {
     // How did I get the exception injected?
     // What magic knows Request and RouteMatchInterface are in that order ?!
  }
}

Understanding ControllerResolver::doGetArguments

In Drupal 8 there is the controller_resolver service which maps to the \Drupal\Core\Controller\ControllerResolver class. This extends the controller resolver class provided by Symfony. From what I can understand, this is done to use Drupalisms added to the routing system, along with some other goodies. Our main concern, however, is the Drupal override of the doGetArguments method. This method is where the magic happens.

Determining the parameters for a controller callback

The doGetArguments method is a protected helper method that is invoked by ControllerResolver::getArguments. Before calling doGetArguments the controller definition is passed through PHP's Reflection API to detect its required arguments. In our example above, \ReflectionMethod will be used to find out the parameters for our on404 method. The code would look something like this.

$r = new \ReflectionMethod(
  '\Drupal\contribkanban_pages\Controller\PagesController',
  'on404'
);

This allows us to access the parameters of the method and pass them to doGetArguments.

return $this->doGetArguments($request, $controller, $r->getParameters());

This will leave us with an array loosely representing the following as ReflectionParameter objects.

  • A parameter called exception that expects \Exception
  • A parameter called request that expects Request
  • A parameter called route_match that expects RouteMatchInterface

Now, let's dig into how they get resolved into proper values and in their proper parameter order.

Putting the right things in the right place

Now that the system knows the expected parameters, it can loop through them and attempt to provide values. This is the purpose of the overridden doGetArguments method in Drupal. I am going to walk through the lines of the function, but the method in its entirety can be reviewed on the Drupal API documentation page.

Before looping through the array of ReflectionParameter objects, attributes are extracted from the request object.

$attributes = $request->attributes->all();
$raw_parameters = $request->attributes->has('_raw_variables') ? $request->attributes->get('_raw_variables') : [];

Here's an example output, taken from a 404 page. It is worth noting that the content of the request attributes is not consistent. In this case, the exception value is only available on 4xx and 5xx requests.

Thumbnail

With these values primed, each parameter is run against a series of checks.

First, the method checks if the parameter name matches an array key in the request attributes or raw variables. If there is a match, the value of the request is taken as the parameter variable.

if (array_key_exists($param->name, $attributes)) {
  $arguments[] = $attributes[$param->name];
}
elseif (array_key_exists($param->name, $raw_parameters)) {
  $arguments[] = $attributes[$param->name];
}

It is worth noting here that route parameters have been upcasting by ParamConverter services at this point. That is why the value is first pulled from the $attributes array. In the $raw_parameters array, it has not been upcasted. If we were on /node/{node} the node parameter would be a loaded node in $attributes and still the node ID (1234) in $raw_parameters.

The next check is to see if the parameter is the request object itself.

elseif ($param->getClass() && $param->getClass()->isInstance($request)) {
  $arguments[] = $request;
}

The next parameter check allows you to receive the server-side representation of the HTTP request, as a PSR7 ServerRequestInterface object. The object returned is a Zend\Diactoros\ServerRequest instance. This allows you to have an immutable state for inspection and true representation of the request, unmodified by any other scripts.

elseif ($param->getClass() && $param->getClass()->name === ServerRequestInterface::class) {
  $arguments[] = $this->httpMessageFactory->createRequest($request);
}

Next is the check to see if a RouteMatch was requested. The route match makes it easier to work with the current route and parameters passed to it.

elseif ($param->getClass() && ($param->getClass()->name == RouteMatchInterface::class || is_subclass_of($param->getClass()->name, RouteMatchInterface::class))) {
  $arguments[] = RouteMatch::createFromRequest($request);
}

The final check applies the parameter's default value if provided.

elseif ($param->isDefaultValueAvailable()) {
  $arguments[] = $param->getDefaultValue();
}

If the parameter met none of the conditions an exception is raised. An exception is raised because the system cannot reliably provide parameter values for the route callback.

else {
  if (is_array($controller)) {
    $repr = sprintf('%s::%s()', get_class($controller[0]), $controller[1]);
  }
  elseif (is_object($controller)) {
    $repr = get_class($controller);
  }
  else {
    $repr = $controller;
  }

  throw new \RuntimeException(sprintf('Controller "%s" requires that you provide a value for the "$%s" argument (because there is no default value or because there is a non optional argument after this one).', $repr, $param->name));
}

If you reach this point, you will see the cursed white screen of death and a message like the following:

The website encountered an unexpected error. Please try again later.</br></br><em class="placeholder">RuntimeException</em>: Controller &quot;Drupal\contribkanban_pages\Controller\PagesController::on404()&quot; requires that you provide a value for the &quot;$server&quot; argument (because there is no default value or because there is a non optional argument after this one).

Debugging \RuntimeExceptions from parameters

Now, hopefully, parameter resolving for controller callbacks has been cleared up. Whenever you hit bugs you can now know to go and debug that exception in \Drupal\Core\Controller\ControllerResolver::doGetArguments.

  • Is there a typo in your parameter in the controller method?
  • If a route parameter, is there a typo in your routing.yml?
  • Did you request a request attribute that is not available (like exception on a valid route)?
  • Were you expecting an upcasted value that was not upcasted?

I was inspired to write the post based on a debugging session in Drupal Slack. The following was their routing.yml definition

commerce_coupon_batch.data_export:
  path: '/promotion/{commerce_promotion}/coupons/export1'
  defaults:
    _controller: '\Drupal\commerce_coupon_batch\Controller\ExportController::exportRedirect'
    _title: 'Export Coupons'
  options:
    _admin_route: TRUE
    parameters:
      commerce_promotion:
        type: 'entity:commerce_promotion'
  requirements:
    _permission: 'administer commerce_promotion'

The route was throwing an exception, even though the route parameter had the proper entity type provided. In Drupal, if the route parameter matches an entity type, the routing system will try to load an entity with that value (identifier or machine name.) Here's an example of the controller callback.

class ExportController extends ControllerBase {
  public function exportRedirect(Promotion $promotion) {
    // Logic.
  }
}

Did you notice the bug, after going through the parameter resolving process? The method expects a parameter named promotion yet our route parameter is called commerce_promotion. There is no matching route attribute, variable, or default value. Therefore the system crashes.

Resources

Here's a summary of documentation links that I mentioned in the article