Drupal 8 page rendering

How does a page get rendered in Drupal? This is a question I felt I had a good handle on in Drupal 7, but although I understand the mechanisms underlying Drupal 8 I couldn't have given you a good explanation of how you get from a request to a rendered page. Perhaps you feel the same way—if that's the case, join me on the journey to figure out how Drupal 8 makes a page.

I didn't find a lot of existing material on this looking around the internet—this article from x-team gives an explanation of how the Drupal kernel works, but doesn't go much deeper than how it handles the initial request. There's a great breakdown of the Symfony HttpKernel which I'd recommend reading, but this obviously doesn't touch on any Drupal specifics.

After writing the initial version of this post, I was pointed towards a page which gives more of an overview of the Drupal render pipeline, along with this great diagram. This would have been useful to help me figure out what was going on, but on the other hand I think this post is more useful to show how I found my way there regardless.

We start in index.php:

use Drupal\Core\DrupalKernel;
use Symfony\Component\HttpFoundation\Request;

$autoloader = require_once 'autoload.php';

$kernel = new DrupalKernel('prod', $autoloader);

$request = Request::createFromGlobals();
$response = $kernel->handle($request);
$response->send();

$kernel->terminate($request, $response);

The Symfony kernel is initially overridden by DrupalKernel, a class which implements the Symfony KernelInterface via DrupalKernelInterface, and hence can easily override the Symfony HttpKernel.

First the DrupalKernel is instantiated, and the global server variables are built into a Symfony Request object. After we have a kernel and a request, the handle() method is called to generate a Symfony Response.

The handle() method doesn't immediately do anything particularly interesting:

public function handle(Request $request, $type = self::MASTER_REQUEST, $catch = TRUE) {
  // Ensure sane PHP environment variables.
  static::bootEnvironment();

  try {
    $this->initializeSettings($request);

    // Redirect the user to the installation script if Drupal has not been
    // installed yet (i.e., if no $databases array has been defined in the
    // settings.php file) and we are not already installing.
    if (!Database::getConnectionInfo() &&
        !InstallerKernel::installationAttempted() &&
        PHP_SAPI !== 'cli') {
      $response = new RedirectResponse(
        $request->getBasePath() . '/core/install.php',
        302,
        ['Cache-Control' => 'no-cache']
      );
    }
    else {
      $this->boot();
      $response = $this->getHttpKernel()->handle($request, $type, $catch);
    }
  }
  catch (\Exception $e) {
    if ($catch === FALSE) {
      throw $e;
    }

    $response = $this->handleException($e, $request, $type);
  }

  // Adapt response headers to the current request.
  $response->prepare($request);

  return $response;
}

First it makes sure that Drupal is installed—if not, the user is redirected to the install page and the usual routing process ends. Otherwise, the request is passed down the chain to $this->getHttpKernel()->handle(). This retrieves the kernel from the core http_kernel service, which by default is:

http_kernel:
  class: Stack\StackedHttpKernel

The StackedHttpKernel is a Symfony Dependency Injection class, again implementing a Symfony interface HttpKernelInterface.

The handle() method here is equally as exciting:

public function handle(Request $request, $type = HttpKernelInterface::MASTER_REQUEST, $catch = true) {
  return $this->app->handle($request, $type, $catch);
}

Instead of being injected via the service definition, $app and $middleware are supplied as part of the Kernel compilation process in StackedKernelPass->process(). By inspecting the application at runtime we can see that $app is an instance of NegotiationMiddleware and $middleware is a stack comprised of:

  1. NegotiationMiddleware
  2. ReverseProxyMiddleware
  3. PageCache
  4. KernelPreHandle
  5. Session
  6. HttpKernel

These are all services tagged with http_middleware, as we can see from the discovery in StackedKernelPass. If you've got access to Drupal Console, you can easily find all of these with:

> drupal debug:container --tag=http_middleware
Service ID                        Class Name
http_middleware.kernel_pre_handle Drupal\Core\StackMiddleware\KernelPreHandle
http_middleware.negotiation       Drupal\Core\StackMiddleware\NegotiationMiddleware
http_middleware.page_cache        Drupal\page_cache\StackMiddleware\PageCache
http_middleware.reverse_proxy     Drupal\Core\StackMiddleware\ReverseProxyMiddleware
http_middleware.session           Drupal\Core\StackMiddleware\Session

The first five classes are all Drupal classes, with the HttpKernel being the default Symfony Kernel as supplied by the service tagged with http_kernel.basic. It's not entirely obvious how these all get called from their service definitions in core.services.yml since none of them have arguments, yet all of them have a reference to the next piece of middleware in the stack. The answer lies again in the StackedKernelPass class:

foreach ($middlewares as $id => $decorator) {
  // Prepend a reference to the middlewares container parameter.
  array_unshift($middlewares_param, new Reference($id));

  // Prepend the inner kernel as first constructor argument.
  $arguments = $decorator->getArguments();
  array_unshift($arguments, new Reference($decorated_id));
  $decorator->setArguments($arguments);

  if ($first_responder === $id) {
    $first_responder = FALSE;
  }
  elseif ($first_responder) {
    $decorator->setLazy(TRUE);
  }

  $decorated_id = $id;
}

Here $middleware is an array of the services tagged with http_middleware ordered from last to first invoked. The value of $decorated_id is initially set to http_kernel.basic, which you'll remember as the service name for the HttpKernel, and is assigned as an argument for the last service invoked. As we iterate through, each middleware has the next middleware in the stack assigned as an argument for the service creation. The Reference class used here is again part of the Symfony Dependency Injection framework, and is a programmatic reference to a service by name. Check out the dependency injection documentation for more detail.

Here are the handle() methods from all of those middlewares:

NegotiationMiddleware

public function handle(Request $request, $type = self::MASTER_REQUEST, $catch = TRUE) {
  // Register available mime types.
  foreach ($this->formats as $format => $mime_type) {
    $request->setFormat($format, $mime_type);
  }

  // Determine the request format using the negotiator.
  if ($requested_format = $this->getContentType($request)) {
    $request->setRequestFormat($requested_format);
  }
  return $this->app->handle($request, $type, $catch);
}

ReverseProxyMiddleware

public function handle(Request $request, $type = self::MASTER_REQUEST, $catch = TRUE) {
  $this->drupalKernel->preHandle($request);

  return $this->httpKernel->handle($request, $type, $catch);
}

PageCache

Note the check for the master request here is due to the ability for the kernel to be passed sub-requests as detailed in the Symfony kernel documentation.

public function handle(Request $request, $type = self::MASTER_REQUEST, $catch = TRUE) {
  // Only allow page caching on master request.
  if ($type === static::MASTER_REQUEST
      && $this->requestPolicy->check($request) === RequestPolicyInterface::ALLOW) {
    $response = $this->lookup($request, $type, $catch);
  }
  else {
    $response = $this->pass($request, $type, $catch);
  }

  return $response;
}

KernelPreHandle

public function handle(Request $request, $type = self::MASTER_REQUEST, $catch = TRUE) {
  // Register available mime types.
  foreach ($this->formats as $format => $mime_type) {
    $request->setFormat($format, $mime_type);
  }

  // Determine the request format using the negotiator.
  if ($requested_format = $this->getContentType($request)) {
    $request->setRequestFormat($requested_format);
  }
  return $this->app->handle($request, $type, $catch);
}

Session

public function handle(Request $request, $type = self::MASTER_REQUEST, $catch = TRUE) {
  if ($type === self::MASTER_REQUEST && PHP_SAPI !== 'cli') {
    $session = $this->container->get($this->sessionServiceName);
    $session->start();
    $request->setSession($session);
  }

  $result = $this->httpKernel->handle($request, $type, $catch);

  if ($type === self::MASTER_REQUEST && $request->hasSession()) {
    $request->getSession()->save();
  }

  return $result;
}

HttpKernel

public function handle(Request $request, $type = HttpKernelInterface::MASTER_REQUEST, $catch = true) {
  $request->headers->set('X-Php-Ob-Level', ob_get_level());

  try {
    return $this->handleRaw($request, $type);
  } catch (\Exception $e) {
    if ($e instanceof RequestExceptionInterface) {
      $e = new BadRequestHttpException($e->getMessage(), $e);
    }
    if (false === $catch) {
      $this->finishRequest($request, $type);

      throw $e;
    }

    return $this->handleException($e, $request, $type);
  }
}
private function handleRaw(Request $request, $type = self::MASTER_REQUEST) {
  $this->requestStack->push($request);

  // request
  $event = new GetResponseEvent($this, $request, $type);
  $this->dispatcher->dispatch(KernelEvents::REQUEST, $event);

  if ($event->hasResponse()) {
    return $this->filterResponse($event->getResponse(), $request, $type);
  }

  // load controller
  if (false === $controller = $this->resolver->getController($request)) {
    throw new NotFoundHttpException(
      sprintf('Unable to find the controller for path "%s". The route is wrongly configured.', $request->getPathInfo())
    );
  }

  $event = new FilterControllerEvent($this, $controller, $request, $type);
  $this->dispatcher->dispatch(KernelEvents::CONTROLLER, $event);
  $controller = $event->getController();

  // controller arguments
  $arguments = $this->argumentResolver->getArguments($request, $controller);

  $event = new FilterControllerArgumentsEvent($this, $controller, $arguments, $request, $type);
  $this->dispatcher->dispatch(KernelEvents::CONTROLLER_ARGUMENTS, $event);
  $controller = $event->getController();
  $arguments = $event->getArguments();

  // call controller
  $response = \call_user_func_array($controller, $arguments);

  // view
  if (!$response instanceof Response) {
    $event = new GetResponseForControllerResultEvent($this, $request, $type, $response);
    $this->dispatcher->dispatch(KernelEvents::VIEW, $event);

    if ($event->hasResponse()) {
      $response = $event->getResponse();
    }

    if (!$response instanceof Response) {
      $msg = sprintf('The controller must return a response (%s given).', $this->varToString($response));

      // the user may have forgotten to return something
      if (null === $response) {
        $msg .= ' Did you forget to add a return statement somewhere in your controller?';
      }
      throw new \LogicException($msg);
    }
  }

  return $this->filterResponse($response, $request, $type);
}

So we can see that HttpKernel->handleRaw() dispatches a bunch of events, but doesn't do much else. The page rendering, therefore, must be handled by one of these event subscribers. We could reasonably assume that KernelEvents::REQUEST, as the first event dispatched, is the one we're looking for.

Let's take a look at what subscribes to this event:

> drupal debug:event kernel.request
  -------------------------------------------------------------------------------- -----------------------------------
  Class                                                                            Method
  -------------------------------------------------------------------------------- -----------------------------------
  Drupal\Core\EventSubscriber\OptionsRequestSubscriber                             onRequest: 1000
  Drupal\Core\EventSubscriber\RedirectLeadingSlashesSubscriber                     redirect: 1000
  Drupal\Core\EventSubscriber\AuthenticationSubscriber                             onKernelRequestAuthenticate: 300
  Drupal\system\TimeZoneResolver                                                   setDefaultTimeZone: 299
  Drupal\Core\EventSubscriber\AjaxResponseSubscriber                               onRequest: 50
  Symfony\Component\HttpKernel\EventListener\RouterListener                        onKernelRequest: 32
  Drupal\Core\EventSubscriber\AuthenticationSubscriber                             onKernelRequestFilterProvider: 31
  Drupal\user\EventSubscriber\MaintenanceModeSubscriber                            onKernelRequestMaintenance: 31
  Drupal\Core\EventSubscriber\MaintenanceModeSubscriber                            onKernelRequestMaintenance: 30
  Drupal\dynamic_page_cache\EventSubscriber\DynamicPageCacheSubscriber             onRequest: 27
  Drupal\Core\Database\ReplicaKillSwitch                                           checkReplicaServer: 0
  Drupal\Core\Routing\RoutePreloader                                               onRequest: 0
  -------------------------------------------------------------------------------- -----------------------------------

Nothing interesting here. Maybe it's one of the others? Let's take a different approach.

If we drop a breakpoint into template_preprocess_html() we can see that the render request originates from the KernelEvents::VIEW event. Now that we know which event we're interested in, we can again use Drupal console to find the subscribers:

> drupal debug:event kernel.view
  ---------------------------------------------------------- ----------------------
  Class                                                      Method
  ---------------------------------------------------------- ----------------------
  Drupal\Core\Form\EventSubscriber\FormAjaxSubscriber        onView: 1
  Drupal\Core\EventSubscriber\PsrResponseSubscriber          onKernelView: 0
  Drupal\Core\EventSubscriber\MainContentViewSubscriber      onViewRenderArray: 0
  Drupal\Core\EventSubscriber\RenderArrayNonHtmlSubscriber   onRespond: -10
  ---------------------------------------------------------- ----------------------

The one that does the actual page rendering is MainContentViewSubscriber—the rendering originates from the MainContentViewSubscriber->onViewRenderArray() callback. This then renders the page using the available %main_content_renderers% based on the $wrapper format:

$wrapper = $request->query->get(static::WRAPPER_FORMAT, 'html');

// Fall back to HTML if the requested wrapper envelope is not available.
$wrapper = isset($this->mainContentRenderers[$wrapper]) ? $wrapper : 'html';

$renderer = $this->classResolver->getInstanceFromDefinition($this->mainContentRenderers[$wrapper]);
$response = $renderer->renderResponse($result, $request, $this->routeMatch);

The subscriber service definition:

main_content_view_subscriber:
  class: Drupal\Core\EventSubscriber\MainContentViewSubscriber
  arguments: ['@class_resolver', '@current_route_match', '%main_content_renderers%']
  tags:
    - { name: event_subscriber }

An example of a content renderer service definition:

main_content_renderer.html:
  class: Drupal\Core\Render\MainContent\HtmlRenderer
  arguments:
    - '@title_resolver'
    - '@plugin.manager.display_variant'
    - '@event_dispatcher'
    - '@module_handler'
    - '@renderer'
    - '@render_cache'
    - '%renderer.config%'
  tags:
    - { name: render.main_content_renderer, format: html }

Content renderers are collected from services tagged with name: render.main_content_renderer as part of the Kernel compilation by another CompilerPass class—MainContentRenderersPass:

/**
* Adds main_content_renderers parameter to the container.
*/
class MainContentRenderersPass implements CompilerPassInterface {

  /**
  * {@inheritdoc}
  *
  * Collects the available main content renderer service IDs into the
  * main_content_renderers parameter, keyed by format.
  */
  public function process(ContainerBuilder $container) {
    $main_content_renderers = [];

    foreach ($container->findTaggedServiceIds('render.main_content_renderer') as $id => $attributes_list) {
      foreach ($attributes_list as $attributes) {
        $format = $attributes['format'];
        $main_content_renderers[$format] = $id;
      }
    }

    $container->setParameter('main_content_renderers', $main_content_renderers);
  }

}

The core renderers available by wrapper are:

html = "main_content_renderer.html"
drupal_ajax = "main_content_renderer.ajax"
iframeupload = "main_content_renderer.ajax"
drupal_dialog = "main_content_renderer.dialog"
drupal_dialog.off_canvas = "main_content_renderer.off_canvas"
drupal_dialog.off_canvas_top = "main_content_renderer.off_canvas_top"
drupal_modal = "main_content_renderer.modal"

There's also a test example in common_test.services.yml.

We can see an instance of practical invocation in ajax.js, where the format is assigned as Drupal.ajax.WRAPPER_FORMAT. In the AJAX options, it's then set up as:

var wrapper = "drupal_".concat(elementSettings.dialogType || 'ajax');

if (elementSettings.dialogRenderer) {
  wrapper += ".".concat(elementSettings.dialogRenderer);
}

ajax.options.url += "".concat(Drupal.ajax.WRAPPER_FORMAT, "=").concat(wrapper);

As we can see from the drupal_ prefix used for wrapper, with the exception of html the other built-in wrappers are all expected to be invoked via JavaScript. The iframeupload wrapper is an altered value for ajax_iframe_upload as seen in NegotiationMiddleware->getContentType(), which is again sent from ajax.js.

Page rendering

We can see what goes down in the MainContentViewSubscriber—the event subscriber method is onViewRenderArray:

public function onViewRenderArray(GetResponseForControllerResultEvent $event) {
  $request = $event->getRequest();
  $result = $event->getControllerResult();

  // Render the controller result into a response if it's a render array.
  if (is_array($result) && ($request->query->has(static::WRAPPER_FORMAT) || $request->getRequestFormat() == 'html')) {
    $wrapper = $request->query->get(static::WRAPPER_FORMAT, 'html');

    // Fall back to HTML if the requested wrapper envelope is not available.
    $wrapper = isset($this->mainContentRenderers[$wrapper]) ? $wrapper : 'html';

    $renderer = $this->classResolver->getInstanceFromDefinition($this->mainContentRenderers[$wrapper]);
    $response = $renderer->renderResponse($result, $request, $this->routeMatch);
    // The main content render array is rendered into a different Response
    // object, depending on the specified wrapper format.
    if ($response instanceof CacheableResponseInterface) {
      $main_content_view_subscriber_cacheability = (new CacheableMetadata())->setCacheContexts(['url.query_args:' . static::WRAPPER_FORMAT]);
      $response->addCacheableDependency($main_content_view_subscriber_cacheability);
    }
    $event->setResponse($response);
  }
}

The $result is retrieved from the controller, and is then passed to the renderer—in this case the html renderer, predictably named HtmlRenderer.

This renders the page variant supplied by the controller, which has to be a class which implements PageVariantInterface. In most cases this is just going to end up rendering an array with '#type' => 'page'.

The HtmlRenderer also handles the page regions and attachments in the prepare() method:

// $page is now fully built. Find all non-empty page regions, and add a
// theme wrapper function that allows them to be consistently themed.
$regions = \Drupal::theme()->getActiveTheme()->getRegions();
foreach ($regions as $region) {
  if (!empty($page[$region])) {
    $page[$region]['#theme_wrappers'][] = 'region';
    $page[$region]['#region'] = $region;
  }
}

// Allow hooks to add attachments to $page['#attached'].
$this->invokePageAttachmentHooks($page);

We can also see that the page title is derived and set here, either from the #title property on the main content array, or via the _title or _title_callback methods on the controller via TitleResolver. As we can see above from the service definition for main_content_renderer.html, this title resolution can be completely overridden by re-implementing the title_resolver service, giving us a large degree of control over how page titles are created.

It's worth looking at how we get the result from the controller, since this tells us a bit more about how the event system works in this core rendering path. We can see in MainContentViewSubscriber the event we're passed is an instance of GetResponseForControllerResultEvent, which already has the response from the controller available via getControllerResult(). With some searching, we can see this event being dispatched from the HttpKernel we were looking at earlier;

$event = new GetResponseForControllerResultEvent($this, $request, $type, $response);
$this->dispatcher->dispatch(KernelEvents::VIEW, $event);

Prior to this event being dispatched in HttpKernel->handleRaw(), we can also see the controller response being handled by the HttpKernel:

$event = new FilterControllerEvent($this, $controller, $request, $type);
$this->dispatcher->dispatch(KernelEvents::CONTROLLER, $event);
$controller = $event->getController();

// controller arguments
$arguments = $this->argumentResolver->getArguments($request, $controller);

$event = new FilterControllerArgumentsEvent($this, $controller, $arguments, $request, $type);
$this->dispatcher->dispatch(KernelEvents::CONTROLLER_ARGUMENTS, $event);
$controller = $event->getController();
$arguments = $event->getArguments();

// call controller
$response = \call_user_func_array($controller, $arguments);

Again we see the use of events to retrieve both the controller class and the controller arguments, which we could subscribe to if necessary to give high-level control over controller output.

After this, the rendering largely follows the same recipe as in Drupal 7—the regions render their blocks, one of them being the main page content, and blocks can be added in the traditional way by users and other modules. We won't follow the rendering process all the way down, since from here on out it's nothing we haven't seen already.


I hope this was a useful look into how rendering works in Drupal 8—I know it was for me. It's interesting to see how much is compiled into the kernel to improve render performance, and also interesting to see the level to which the rendering relies on creating those core areas—the page and blocks—for other modules to hook into to render the remaining site content.