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 callRunner
), - 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.
Daniel Sipos
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.
Comments
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:
This removes a layer of abstraction and makes using things like translation context much easier.
In reply to Drupal core is exploring this already 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 :)
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 :-))
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