taking control of drupal 9 frontend

Wayne Ashley, Founder
Wayne Ashley
FOUNDER

Apr 2021 • 5 minute read


So today I am taking a look at how to swap out the default NodeViewController in Drupal 9 and replace it with separate controllers based on Node Type eg. PageController, ArticleController etc.

Whilst it is fine to manage your page layouts through a combination of UI Blocks and the Hook System, I prefer something more familiar to the other platforms I work with such as Symfony. So for me this means one Controller per Content Type and loading Blocks programmatically.

In the past I have resolved this by assigning a new NodeController via a RouteEventSubscriber, which works well but does come with a caveat - all nodes regardless of type/bundle are directed to the same Controller.

# @file modules/custom/app/app.services.yml

services:
  mymodule.route_subscriber:
    class: Drupal\app\Routing\RouteSubscriber
    tags:
      - { name: event_subscriber }

With a RouteSubscriber definition in place, the aim is to alter the controller associated with one or more routes.

<?php

declare(strict_types=1);

namespace Drupal\app\Routing;

use Drupal\Core\Routing\RouteSubscriberBase;
use Symfony\Component\Routing\RouteCollection;

/**
 * Listens to the dynamic route events.
 */
class RouteSubscriber extends RouteSubscriberBase {

  /**
   * {@inheritdoc}
   */
  protected function alterRoutes(RouteCollection $collection): void {
    if ($route = $collection->get('entity.node.canonical')) {
      $route->setDefault('_controller', '\Drupal\app\Controller\NodeController::view');
    }
}

So all requests for entity.node.canonical routes are pushed to our NodeController, but what if I want to distinguish between Content Types and assign each to a unique Controller.

Using a Kernel Event Subscriber for controller allocation.

Rather than subscribing to the Route Events, which are only triggered when the site cache is being rebuilt and therefore have no knowledge of the nodes to be associated with subsequent requests, we are able to subscribe to the Kernel::Controller Event which is called when the Kernel assigns a Controller to handle a Route. Critically this gives us access to the requested Route plus any associated Entities, and the ability to set an alternate (Late) Controller should we wish.

Returning to our Service Definitions, we remove the Route Subscriber and replace this with:

  • a Kernel Subscriber, and
  • a Late Controller Plugin Manager
services:
  app.kernel_subscriber:
    class: Drupal\app\EventSubscriber\KernelSubscriber
    arguments: ['@controller_resolver']
    tags:
      - { name: event_subscriber }

  plugin.manager.late_controller:
    class: Drupal\app\PluginManager\LateControllerPluginManager
    parent: default_plugin_manager

Taking a look at our Kernel Subscriber first, we are going to listen for the Controller Event and use our Plugin Manager to decide whether to swap out the currently assigned Controller.

<?php

declare(strict_types=1);

namespace Drupal\app\EventSubscriber;

use Symfony\Component\HttpKernel\Controller\ControllerResolverInterface;
use Symfony\Component\HttpKernel\Event\ControllerEvent;
use Symfony\Component\HttpKernel\KernelEvents;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;

/**
 * Subscribe to kernel events.
 */
class KernelSubscriber implements EventSubscriberInterface {

  /**
   * Constructs a new PathSubscriber instance.
   */
  public function __construct(
    protected ControllerResolverInterface $controllerResolver,
  ) {}


  public static function getSubscribedEvents(): array {
    return [
      KernelEvents::CONTROLLER => 'onController',
    ];
  }

  public function onController(ControllerEvent $event): void {
    $request = $event->getRequest();
    $attributes = $request->attributes;

    if ($attributes->has('_route') === FALSE) {
      return;
    }

    $route = $attributes->get('_route');

    $manager = \Drupal::service('plugin.manager.late_controller');
    if ($manager->hasDefinition($route) === FALSE) {
      return;
    }

    $plugin = $manager->createInstance($route);
    $controller_definition = $plugin->getControllerDefinition($request);

    $controller = $this->controllerResolver->getControllerFromDefinition($controller_definition);
    $event->setController($controller);
  }
}

Here we can see that the Plugin Manager is taking the Route Id from the Request and checking for a Late Controller Plugin of the same Id. If a match is found then the new definition is retrieved from the Plugin and the Controller associated with the Event is updated accordingly.

Back to the grindstone

Good stuff, let's push on through to the end. We now need to define both our Plugin Manager and the Annotation to be associated with it. This is all basically stock, so I won't dwell on them.

<?php

declare(strict_types=1);

namespace Drupal\app\PluginManager;

use Drupal\Component\Plugin\PluginManagerInterface;
use Drupal\Core\Cache\CacheBackendInterface;
use Drupal\Core\Extension\ModuleHandlerInterface;
use Drupal\Core\Plugin\DefaultPluginManager;

/**
 * Plugin manager allowing for late of controller (ie. after kernel has resolved controller from route).
 */
class LateControllerPluginManager extends DefaultPluginManager implements PluginManagerInterface {
  /**
   * Class constructor.
   */
  public function __construct(\Traversable $namespaces, CacheBackendInterface $cache_backend, ModuleHandlerInterface $module_handler) {
    parent::__construct(
      'Plugin/LateController',
      $namespaces,
      $module_handler,
      'Drupal\app\Contract\LateControllerPluginInterface',
      'Drupal\app\Annotation\LateController'
    );
    $this->alterInfo('late_controller_info');
    $this->setCacheBackend($cache_backend, 'late_controller_info_plugins');
  }

}

This gives us our Plugin Manager - I've called these Late Controllers as they are assigned after the Kernel has picked one out already.

<?php

declare(strict_types=1);

namespace Drupal\app\Annotation;

use Drupal\Component\Annotation\Plugin;

/**
 * Defines a LateController annotation object.
 *
 * Additional annotation keys for late controllers can be defined in
 * hook_late_controller_annotation_info_alter().
 *
 * @Annotation
 */
class LateController extends Plugin {

  /**
   * The plugin ID, should refer to a route name.
   *
   * @var string
   */
  public $id;

  /**
   * The human-readable name of the late controller.
   *
   * @var string
   */
  public $label;

  /**
   * A short description of the late controller.
   *
   * @var string
   */
  public $description;

}

With the description in place, all that is left is to create our plugin - note with this method it becomes a trivial exercise to assign further Late Controllers from anywhere else within your codebase.

<?php

declare(strict_types=1);

namespace Drupal\app\Plugin\LateController;

use Drupal\app\Trait\StringUtilityTrait;
use Drupal\app\Contract\LateControllerPluginInterface;
use Drupal\Component\Plugin\PluginBase;
use Symfony\Component\HttpFoundation\Request;

/**
 * Assigns an alternative controller for entity.node.canonical routes based on node type.
 *
 * @LateController(
 *  id = "entity.node.canonical",
 *  label = "Entity Node Canonical",
 *  description = "Alternative controller for entity.node.canonical routes based on node type."
 * )
 */
class NodeCanonicalLateController extends PluginBase implements LateControllerPluginInterface {

  use StringUtilityTrait;

  /**
   * Get the namespaced controller with method.
   */
  public function getControllerDefinition(Request $request): string {
    $attributes = $request->attributes;
    $prefix = $this->snakeToPascal($attributes->get('node')->getType());
    return '\\Drupal\\app\\Controller\\' . $prefix . 'Controller::view';
  }
}

Hopefully nothing too taxing going on there, I've referenced a string utility trait which simply converts the Content Type system name from snake_case to PascalCase, but apart from that you are good to go.

If placing logic on your Controller methods isn't your thing, then this approach is a viable alternative to the Route Subscriber approach.

Keep yourself informed of the latest news from our design agency.