Drupal 9: Extending Drupal Base Classes Without Overriding Constructors

I have been using the Drupal dependency injection system for a while now, even giving talks and writing articles on the subject. As the release of Drupal 10 is just around the corner I have been thinking more about how to override dependencies to plugin classes.

I wanted to share a trick to overriding dependency injection that will make your life easier. The Drupal codebase itself does not use this trick, but it seems that many contributed modules have started to use this trick to create plugin classes.

In this article I will go through how to add dependency injection to Drupal classes in two different patterns.

The first pattern will create problems and will require quite a bit more typing and repeating of code. The second pattern is much easier to use and will make life easier going forward.

An Example Block

The following block of code shows a very basic Drupal Block class that doesn't do anything. This will be the basis of the rest of this article.

<?php

namespace Drupal\mymodule\Plugin\Block;

use Drupal\Core\Block\BlockBase;

/**
 * Provides a 'TEST' block.
 *
 * @Block(
 *  id = "mymodule_block",
 *  label = "A testing block",
 *  admin_label = @Translation("A testing block"),
 * )
 */
class TestBlock extends BlockBase {

  /**
   * {@inheritdoc}
   */
  public function build() {
    $build = [];

    return $build;
  }

}

What I am going to do with this block is inject a service to perform route matching. In Drupal there are a couple of services to do this, but I will be using the current_route_match service to look for the service.

What we need to do first is ensure our block class implements an interface called \Drupal\Core\Plugin\ContainerFactoryPluginInterface. This interface is used to allow plugins to declare dependencies as the object is created. Drupal will look for this interface whilst creating objects and will call a static create() method within the class if the class implements this interface, which must then return an instance of the object.

The usage of the create method is the key difference here, so let's start with the wrong way to inject services.

How Not To Inject Services Into Plugins

By adding the \Drupal\Core\Plugin\ContainerFactoryPluginInterface interface to our class then needs to implement a create() method. This method should take a number of parameters when creating plugins and should return an instance of the class.

This is the default implementation of the create() method for plugin classes.

<?php

namespace Drupal\mymodule\Plugin\Block;

use Drupal\Core\Block\BlockBase;
use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Drupal\Core\Routing\RouteMatchInterface;

/**
 * Provides a 'TEST' block.
 *
 * @Block(
 *  id = "mymodule_block",
 *  label = "A testing block",
 *  admin_label = @Translation("A testing block"),
 * )
 */
class TestBlock extends BlockBase implements ContainerFactoryPluginInterface {

  /**
   * {@inheritdoc}
   */
  public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) {
    return new static(
      $configuration,
      $plugin_id,
      $plugin_definition
    );
  }

// Code snipped for brevity.

It isn't super clear what is going on here, especially if you are a newcomer to PHP, so let's dive into this a little.

What the code in the create() method is doing is essentially creating a new instance of the TestBlock object. This is what "new static()" bit is doing; generating the object that this class represents. When creating a new object the code will also call the constructor of the class (as it would with any other PHP object creation). The parameters we are passing to the static() method here are essentially arguments for the current classes constructor.

In order to add the route matching service to the class we need to add an extra parameter to the static() function. The $container parameter contains a list of all of the available services in Drupal, so we use the get() method to retrieve the relevant service from that list.

This is what the create() method looks like now.

  public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) {
    return new static(
      $configuration,
      $plugin_id,
      $plugin_definition,
      $container->get('current_route_match')
    );
  }

Here is where the problem starts to come in. In order to use the create() method like this we need to override the parent class constructor in order to allow the extra parameters that we need.

This means heading to the parent class and finding the constructor and copying this into the block class we are working on.

This is the code from the Block plugin class that we then copy to our TestBlock class.

  public function __construct(array $configuration, $plugin_id, $plugin_definition) {
    $this->configuration = $configuration;
    $this->pluginId = $plugin_id;
    $this->pluginDefinition = $plugin_definition;
  }

What changing the static() function does is essentially change the footprint of the constructor, so we now need to update the __construct() method to introduce our new service parameter.

Here is the code we end up with, including the updated create() method and the changed constructor.

  /**
   * The current route match.
   *
   * @var \Drupal\Core\Routing\RouteMatchInterface
   */
  protected $routeMatch;

  /**
   * Constructs a new TestBlock instance.
   *
   * @param array $configuration
   *   A configuration array containing information about the plugin instance.
   * @param string $plugin_id
   *   The plugin_id for the plugin instance.
   * @param mixed $plugin_definition
   *   The plugin implementation definition.
   * @param \Drupal\Core\Routing\RouteMatchInterface $route_match
   *   The current route match.
   */
  public function __construct(array $configuration, $plugin_id, $plugin_definition, RouteMatchInterface $route_match) {
    parent::__construct($configuration, $plugin_id, $plugin_definition);

    $this->routeMatch = $route_match;
  }

  /**
   * {@inheritdoc}
   */
  public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) {
    return new static(
      $configuration,
      $plugin_id,
      $plugin_definition,
      $container->get('current_route_match')
    );
  }

// Code snipped for brevity.

When the object is created we will now have access to the RouteMatch object (from the current_route_match service) as we have now injected it into the class definition.

This all makes sense, but things start to go wrong if the base constructor we are extending changes in any way. If this happens then we will need to spend lots of time updating the create() methods as well as the constructor definitions. Due to the fact that we have made extensive changes to both methods we would face a bit of a maintenance problem.

This also has a problem when we need to add more services to the plugin as this means updating the create() method and the constructor to ensure the service is added to the object.

Anything that implements the Drupal interface \Drupal\Core\Plugin\ContainerFactoryPluginInterface or \Drupal\Core\DependencyInjection\ContainerInjectionInterface might have this problem since they both have this create()/__construct() pattern. The ContainerInjectionInterface is used in controllers and forms, but essentially does the same thing.

This isn't strictly "wrong", but if you override your plugin classes like this you might have a larger maintenance issue in the future if any constructor definitions change.

If you've done any amount of reading Drupal's codebase (which I recommend you do anyway) then you'll see this sort of practice going on a lot.

Let's look at a better way to override create methods.

How To Inject Services Into Plugins

A better way to approach this is by using the create() method to create the object and also inject any services we need to use. This means that we don't need to alter the constructor at all as our services are injected as the class is created.

Here is the create() method, where instead of adding the route matching service to the static() function, we add the service it generates to the object after is is created. We then return the object in the same way as before.

  /**
   * The current route match.
   *
   * @var \Drupal\Core\Routing\RouteMatchInterface
   */
  protected $routeMatch;

  /**
   * {@inheritdoc}
   */
  public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) {
    $instance = new static(
      $configuration,
      $plugin_id,
      $plugin_definition
    );

    $instance->routeMatch = $container->get('current_route_match');

    return $instance;
  }

This might look like it shouldn't work as we appear to be accessing a protected property of an object from a static method. It is perfectly fine, however, and when the object is created the "routeMatch" property now contains the Drupal current_route_match service.

Alternatively, you can use an intermediate method to pass the service into the class property.

  protected $routeMatch;

  /**
   * {@inheritdoc}
   */
  public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) {
    $instance = new static(
      $configuration,
      $plugin_id,
      $plugin_definition
    );

    $instance->setRouteMatch($container->get('current_route_match'));

    return $instance;
  }

  /**
   * Set the route match service.
   *
   * @param \Drupal\Core\Routing\RouteMatchInterface $route_match
   *   The Drupal current route matching service.
   */
  public function setRouteMatch($route_match) {
    $this->routeMatch = $route_match;
  }

Using an intermediate method like this is a good idea as it allows unit tests to be written against the class whilst also injecting the appropriately mocked service object into the plugin. Without this method here it is not possible to add the dependencies into the class unless they are public properties (which is discouraged).

Conclusion

This method of dependency injection is not used in Drupal core (that I can see), but appears to be gaining traction in the contributed module world. Looking at modules like ECA or Webform shows this technique in action within their plugin, form and controller classes.

It makes sense to use this method of dependency injection because if the plugin that you have extended changes in any way you only need to alter 1, maybe 2 lines of code. The changes being in the definition of the create() method and the properties being passed to the static() function.

Although I have detailed the use of plugins here, remember that the same method applies to controllers and forms when adding the \Drupal\Core\DependencyInjection\ContainerInjectionInterface interface to the class. The create() method is called in the same way when that interface is used.

More in this series

Comments

I've created a module to help with this for quite a few of the core services. See https://www.drupal.org/project/awareness.

After using this method for a year or so, now I'm considering creating a 2.x version that includes a method that calls the service from the \Drupal object.

Permalink

Thanks Will! I'll take a look :)

Name
Philip Norton
Permalink

AFAIK better to use

$instance = parent::create( $container, $configuration, $plugin_id, $plugin_definition );

Permalink

Hi Jonathan, thanks for commenting!

I can see what you mean. I did try to track down why we use the "new static(" method, but couldn't find out where it had come from. PHPStan complains about it as well.

Name
Philip Norton
Permalink

Thanks Phillip. That's really helpful!

Permalink

Hi Philip! Thanks for the useful article.

I was wondering how to correctly unit test this class, because all dependencies are not "required by constructor". You would have to know exactly what dependencies there are and call the appropiate dependency setter functions with mocks, like your $instance->setRouteMatch($routeMatchMock).

¿Will this cause a problem with the unit tests?

Thanks for your thoughts!

Permalink

Hi Victor, Thanks for commenting!

I don't think it will cause massive issues with unit tests, you just need to write them a little differently. Instead of relying on the auto mocking system to create your objects you would need to inject your (mocked) dependencies using the set methods you created for the create() method. This would have the same effect and allow the tests to run correctly.

Name
Philip Norton
Permalink

Makes sense.
I've integrated it one of my projects as a standard via a generator
 

Permalink

Great article. I think this is a useful pattern.

I think Drupal's use of "new static()" is problematic, and phpstan doesn't like it, but I'm not sure how it could be fixed. Declaring the constructor as "final" makes phpstan happy, but it it restricts how the module may be inherited. What seems to be needed is some way of telling PHP (or maybe just phpstan) that any derived class that overrides the constructor must also override create().

Permalink

Thanks for the comment James.

As it happens I have talked about this problem before in an article on Running PHPStan On Drupal Custom Modules. What you need to do is to add the following to your phpstan.neon file.

    ignoreErrors:
        - '#Unsafe usage of new static\(\)#'

That should keep phpstan happy about it.

The other solution of setting the class to final isn't a solution in my eyes. I have rarely come across a situation where I have absolutely needed to use a final class.

According to the phpstan docs, the rule is there to prevent you from breaking your own code when you extend the class and change the constructor in some way (see https://phpstan.org/blog/solving-phpstan-error-unsafe-usage-of-new-stat…). So it's more a rule based on preventing errors when editing code, rather than preventing issues with running the code.

Name
Philip Norton
Permalink

Add new comment

The content of this field is kept private and will not be shown publicly.
CAPTCHA
3 + 0 =
Solve this simple math problem and enter the result. E.g. for 1+3, enter 4.
This question is for testing whether or not you are a human visitor and to prevent automated spam submissions.