Profile picture for user admin
Daniel Sipos
01 Feb 2022

You can follow the Drupal.org issue where annotations are going to be replaced by attributes.

PHP 8 came with a lot of cool new features in the language. Among them, we finally have a native way of “annotating” classes, methods and all sorts of things. I used quotes because of the very ubiquitous Annotations library from Doctrine which we are using now to do similar things. PHP attributes are on their way to slowly replace those. I think. Don’t hold me to it though.

In this article, I wanted to show you my exploration of attributes in a Drupal context. And how better than by creating a new discovery mechanisms for plugins based on attributes? Do keep in mind that what I created is a proof of concept (tldr, yes, it works) and it’s heavily inspired by the existing annotations based discovery system which most plugins use.

Before we start, it would make sense for you to get an understanding of how attributes work. And since there is quite a lot of information available on this already, I will defer to that instead of covering it myself. This here is a nice primer if you want to give it a read.

The discovery mechanism

Discovery is one of the main parts of the plugin architecture. It is responsible for finding the plugins in defined locations. Shocker. The most common discovery mechanisms in Drupal are the ones using annotations (see Block) but a popular one is also the YAML based one (see menu links).

At its most basic level, the discovery is a simple class that, given some “configuration”, looks in the places it knows to load plugin data. The annotations-based one looks for files in certain folders (usually Plugin/[Plugin type name] folder of each module) and at the annotations above the classes it finds in those folders. The YAML-based one looks for yaml files of a certain defined structure.

Typically, when we create a new plugin type, the central player for it is the plugin manager. This will usually extend the DefaultPluginManager which, if left on its own devices, will use the annotations discovery. Check out its getDiscovery() method for how it instantiates the discovery class.

Attribute discovery

So how does this attribute discovery system works? Simple. Its central player is a class that is meant to be instantiated by a plugin manager and which does similar things to what the annotations discovery does. I won’t go into details here because there is quite a lot of boilerplate code but you can check out this repository for the actual code, especially the AttributeClassDiscovery class.

In addition, we also have two attribute classes that help us define the plugin type attributes.

First, we have PluginAttribute, which is the most important and which will be extended by a class specific to each plugin type. Very similar to the Drupal\Component\Annotation\Plugin base class for the annotation discovery.

Second, we have a Translatable attribute class which we will use to define translatable definition elements. We’ll see in a moment but this was my creative way of circumventing the fact that attributes cannot be nested like annotations can. And that’s basically it for the discovery mechanism.

In order to understand better how this works, let us define a plugin type that uses this discovery.

The “Runner” plugin type

Again, going forward, I will assume you are more or less aware of how to create a plugin type in Drupal. If not, I recommend you brush up on this topic first.

To have our plugin type, we need 4 main classes:

  • an interface for our plugins (which we will call RunnerInterface),
  • a base class that Runner plugins will extend from (which we will call RunnerPluginBase),
  • an attribute class that extends the PluginAttribute we mentioned above (which we will call Runner),
  • the plugin manager that ties them all together, that we will call RunnerPluginManager.

So let’s start in this order. Follow the namespaces and class names to determine the correct folder placements and file names.

First, the interface, followed by the base plugin class that implements it:

namespace Drupal\runner;

/**
 * Interface for runner plugins.
 */
interface RunnerInterface {

  /**
   * Returns the translated plugin label.
   *
   * @return string
   *   The translated title.
   */
  public function label();

}

Very simple, doesn’t do much, and we have below also the base plugin class:

namespace Drupal\runner;

use Drupal\Component\Plugin\PluginBase;

/**
 * Base class for runner plugins.
 */
abstract class RunnerPluginBase extends PluginBase implements RunnerInterface {

  /**
   * {@inheritdoc}
   */
  public function label() {
    // Cast the label to a string since it is a TranslatableMarkup object.
    return (string) $this->pluginDefinition['label'];
  }

}

All standard stuff we do also with plugin types that use other discovery mechanisms. But now is where we start to diverge as we need to define the attribute class that will be used:

namespace Drupal\runner\PluginAttribute;

use Attribute;
use Drupal\attribute_discovery\Plugin\Attribute\PluginAttribute;

/**
 * Attribute class for the Runner instances.
 */
#[Attribute(Attribute::TARGET_CLASS)]
class Runner extends PluginAttribute {}

This is very similar to how we do with annotation plugins where we instead define the “annotation” class that extends from Drupal\Component\Annotation\Plugin. However, a very important difference is that we mark this class as an attribute with the following notation:

#[Attribute]

Moreover, we use the Attribute::TARGET_CLASS target to specify that this attribute can only be used for classes (as opposed to methods or properties).

Finally, the most important part, the plugin manager:

namespace Drupal\runner;

use Drupal\attribute_discovery\Plugin\Discovery\AttributeClassDiscovery;
use Drupal\Core\Cache\CacheBackendInterface;
use Drupal\Core\Extension\ModuleHandlerInterface;
use Drupal\Core\Plugin\DefaultPluginManager;
use Drupal\Core\Plugin\Discovery\ContainerDerivativeDiscoveryDecorator;

/**
 * Runner plugin manager.
 */
class RunnerPluginManager extends DefaultPluginManager {

  /**
   * Constructs a RunnerPluginManager object.
   */
  public function __construct(\Traversable $namespaces, CacheBackendInterface $cache_backend, ModuleHandlerInterface $module_handler) {
    $this->subdir = 'Plugin/Runner';
    $this->namespaces = $namespaces;
    $this->pluginInterface = '\Drupal\runner\RunnerInterface';
    $this->moduleHandler = $module_handler;
    $this->alterInfo('runner_info');
    $this->setCacheBackend($cache_backend, 'runner_plugins');
  }

  /**
   * {@inheritdoc}
   */
  protected function getDiscovery() {
    if (!$this->discovery) {
      $discovery = new AttributeClassDiscovery($this->subdir, $this->namespaces, '\Drupal\runner\PluginAttribute\Runner');
      $this->discovery = new ContainerDerivativeDiscoveryDecorator($discovery);
    }

    return $this->discovery;
  }

}

…and it’s service definition:

services:
  plugin.manager.runner:
    class: Drupal\runner\RunnerPluginManager
    parent: default_plugin_manager

Also similar to how we do with the annotation-based plugins, we extend from DefaultPluginManager. Though technically this is not required, we do benefit from some of the logic this parent class does. To note is that we kept the Plugin/Runner folder structure where plugin classes actually go, just like with annotation plugins.

The getDiscovery() method is where we then employ the attribute discovery class, whose constructor takes as a third argument the expected attribute class that we defined earlier (\Drupal\runner\PluginAttribute\Runner). Like usual, also, we then wrap this discovery with the ContainerDerivativeDiscoveryDecorator so that we can have plugin derivatives if we need to (which will work exactly as expected).

And with this we are done. Let’s now create our first plugin, called Foo, to illustrate how the system actually works:

namespace Drupal\runner\Plugin\Runner;

use Drupal\attribute_discovery\Plugin\Attribute\Translatable;
use Drupal\runner\RunnerPluginBase;
use Drupal\runner\PluginAttribute\Runner;

#[Runner([
  'id'=> 'foo',
  'label' => 'Foo',
  'description' => 'This is a test plugin'
])]
/**
 * Foo plugin.
 */
class Foo extends RunnerPluginBase {}

Here is the barebones plugin definition. The first thing to notice is that above the class, we use our Runner attribute, to which we pass a definition array of data. This data is very much the same as you’d expect in a plugin annotation.

This is all good and well but there is one problem: translatability. Let’s imagine how the annotation would look for this same plugin definition:

/**
 * @Runner(
 *   id = "foo",
 *   label = @Translation("Foo"),
 *   description = @Translation("This is a test plugin")
 * )
 */

See the difference? The label and description are run through a nested annotation, called Translation, which turns those values into TranslatableMarkup objects. And we have a problem because attributes do not support nested attributes.

So what was my super duper creative way of solving this issue? Another, attribute, called Translatable, which takes in a map keyed by the definition key that is translatable and whose values are an array of contextual arguments to replace from the string (the second parameter to TranslatableMarkup basically). So the plugin class attributes would now look like this:

#[Runner([
  'id'=> 'foo',
  'label' => 'Foo',
  'description' => 'This is a test plugin',
]), Translatable(['label' => [], 'description' => []])]

There is nothing that says a class cannot use two attributes. In our case, the first one defines the plugin definition, and the second marks which items from the definition are translatable (label and description, neither of them having any contextual arguments to replace). Then, the plugin discovery system looks at these and processes the definition to turn those values into translatable markup. Of course, we are missing the context option but this can be reworked to handle that as well. This is a POC after all, and I’m not even sure if this is the best way we can mark translatable strings.

And with this we are done. We can now get definitions or create instances of plugins like usual:

$manager =\Drupal::service('plugin.manager.runner');
$definitions = $manager->getDefinitions();
$plugin = $manager->createInstance('foo');

And that’s pretty much it. As a bonus, derivatives also work the same way, simply by referencing the deriver class within the plugin definition, exactly as with an annotation.

Hope this was interesting.

Profile picture for user admin

Daniel Sipos

CEO @ Web Omelette

Danny founded WEBOMELETTE in 2012 as a passion project, mostly writing about Drupal problems he faced day to day, as well as about new technologies and things that he thought other developers would find useful. Now he now manages a team of developers and designers, delivering quality products that make businesses successful.

Contact us

Comments

Alex Pott 01 Feb 2022 20:32

Drupal core is exploring this already

There's plenty of discussion on this topic on the core issue exploring using attributes. Have a look at https://www.drupal.org/project/drupal/issues/3252386#new.

Re translatable strings if the minimum version is PHP 8.1 we can do:

#[Block(
  id: "test_context_aware",
  admin_label: new TranslatableMarkup("Test context-aware block"),
  context_definitions: [
    'user' => new EntityContextDefinition(
      data_type: 'entity:user',
      label: new TranslatableMarkup("User Context"),
      required: FALSE,
      constraints: [
        "NotNull" => [],
      ]
    ),
  ]
)]

This removes a layer of abstraction and makes using things like translation context much easier.

Daniel Sipos 01 Feb 2022 20:49

In reply to by Alex Pott (not verified)

Yeah, I know. I linked to…

Yeah, I know. I linked to that at the end of the article because I had seen it just when I published the article. I did this POC in November and at that time the work was not started. I had seen this one one but without any concrete work :)

Dezső Biczó (mxr576) 02 Feb 2022 09:43

Nested attribute support

Nice POC!

Note: nested attribute support arrived with PHP 8.1, would be great if we could use them in Drupal 10

symfony.com/blog/new-in-symfony-5-4-nested-validation-attributes

(spam protection needs some adjustments :-))

gabesullice 03 Feb 2022 16:47

Excellent post

Thanks! This was super informative and it had the perfect level of depth IMO. Versus Doctrine, what advantages and disadvantages did you discover? (pun intended 😉)

Add new comment