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.
Starting our search
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:
NegotiationMiddleware
ReverseProxyMiddleware
PageCache
KernelPreHandle
Session
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.