Un petit billet pour vous parler de la classe Attribute. Cette classe a été introduite dans Drupal 10, mais elle est encore sous-exploitée dans tout ce qui est modules de contribution. Avec Drupal 11, le noyau Drupal a fait la transition depuis les annotations doctrine vers la classe Attribute.
C'est quoi, les annotations doctrine
Pour peu que vous ayez eu à développer des modules qui utilisent des entités ou des plugins, vous avez forcément du passer par des annotations doctrines. Elles permettent de créer la définition de l'élément que vous souhaitez intégrer. Ça ressemble à ça :
/**
* Provides a 'Back menu' block.
*
* @Block(
* id = "backmenu_block",
* admin_label = @Translation("Back Menu block"),
* )
*/
Ici, je déclare un plugin de bloc dont l'identifiant est "backmenu_block", et qui va permettre de créer un bloc avec un menu "Back". Ces annotations sont présentes depuis longtemps dans Drupal (depuis Drupal 8) et se basent sur le système d'annotation Doctrine. Doctrine est un ensemble de bibliothèques PHP, dont un ORM très utilisé dans Symfony. Le système d’annotations utilisé par Drupal repose sur Doctrine Annotations, qui est un composant distinct de l’ORM. Dans Symfony, les annotations doctrine sont utilisées pour mapper des objets relationnels (base de données) vers des classes, afin de pouvoir manipuler les données d'une manière plus simple.
Drupal s'était inspiré de ce système pour la déclaration de tout ce qui concernait les entités et plugins. Le problème ? c'est que ça créait une dépendance avec Doctrine, et que ça limitait certains aspects :
- parsing de commentaires
- erreurs silencieuses si mal déclarés
- typage faible
Classe Attribute
La classe Attribute résout une bonne partie de ces problèmes.
- basé directement sur PHP
- un typage fort
- ajoute l'auto-completion
- plus lisible et compacte
- améliore les performances
Voici à quoi ressemblerait l'annotation précédente, sous forme d'attribut :
use Drupal\Core\Block\Attribute\Block;
#[Block(
id: "backmenu_block",
admin_label: new TranslatableMarkup("Back Menu block")
)]Sur le principe, ça ne semble pas forcément très différent, mais en terme de lisibilité et de performance, c'est une autre histoire.
Cas d'utilisation
Prenons un exemple concret pour illustrer son utilisation. La création d'un plugin spécifique avec son plugin manager. Dans mes projets, j'ai le module responsive_builder sur lequel je travaille depuis un bon moment. Ce dernier utilise un système d'extracteur de données dans le dom pour détecter les classes de colonnage utilisés par le thème (bootstrap ou foundation, tailwind et bulma à venir plus tard). ça sert à rendre mon système agnostique. Le responsive builder n'est pas limité à Bootstrap comme il l'était au départ, il peut maintenant s'adapter à différents systèmes de thèmes et détecter quelles sont les classes utilisées par le conteneur de l'image pour attribuer automatiquement les colonnes à prendre en compte, puis générer les éléments responsive (image styles, responsive style, view-mode, view-display) avec un minimum d'interaction utilisateur.
À la base, seul Bootstrap était supporté, et le code était centralisé dans le fichier JS global du module pour extraire ces informations. Pour intégrer d'autres systèmes de colonnage, j'ai finalement décidé de créer des plugins. Chaque plugin va simplement indiquer un identifiant, un nom, et une librairie correspondant à l'extracteur à utiliser pour détecter les informations.
Côté JS, le JS global va permettre d'inscrire les extracteurs dans un store, et automatiquement utiliser le bon extracteur selon le thème courant en appelant sa méthode "extract". C'est un peu le principe de la Factory, qui fourni différentes instances de classes qui possèdent toute une ou plusieurs méthodes communes. L'appelant ne connait pas la classe exacte, mais fait appel à la méthode connue pour exécuter un processus. Simple, efficace.
Le code
Pour créer un nouvel Attribut, on va utiliser une notation un peu spécifique. Voici celle de mon plugin ColumnExtractor :
<?php
namespace Drupal\responsive_builder\Attribute;
use Drupal\Component\Plugin\Attribute\Plugin;
use Drupal\Core\StringTranslation\TranslatableMarkup;
/**
* The column extractor annotation.
*/
#[\Attribute(\Attribute::TARGET_CLASS)]
class ColumnExtractor extends Plugin {
/**
* Constructor.
*
* @param string $id
* The plugin ID.
* @param \Drupal\Core\StringTranslation\TranslatableMarkup $label
* The plugin label.
* @param string $library
* The plugin JS library.
* The plugin library must match a library in the libraries.yml file of the
* module. Ie: responsive_builder_ultimate/bootstrap_extractor.
*/
public function __construct(
public readonly string $id,
public readonly TranslatableMarkup $label,
public readonly string $library,
) {}
}
Le plus important ici, c'est la première ligne avant la déclaration de classe :
#[\Attribute(\Attribute::TARGET_CLASS)]C'est elle qui dit au système : Ceci est une classe d'attribut, qui pourra être utilisée en tant qu'attribut pour déclarer un élément.
Ensuite, on peut voir que j'ai un constructeur qui va simplement prendre en charge un identifiant, un label et une définition de librairie.
Jusqu'ici, tout va bien, c'est plutôt simple. Mais pour travailler avec mon nouveau type de plugin, je vais avoir besoin d'un PluginManager sous forme de service, qui va pouvoir "découvrir" les plugins de type ColumnExtractor.
Le système de plugin manager Drupal est assez intéressant, car il permet d'itérer sur tous les modules pour aller chercher des classes dans des répertoires particuliers. Dans le cas de mes extracteurs, ils seront placés dans un namespace du type
\Drupal\Plugin\ResponsiveBuilder\ColumnExtractorSi vous ne l'avez pas encore compris, il y a de multiples intérêt à utiliser des plugins dans cette situation...
- Chaque plugin ne connait pas les autres, ni le manager. Il fait juste d'indiquer des informations / méthodes qui pourront être utilisées
- Le manager fait ce qu'on appelle de l'auto-discovery. ça veut dire que n'importe quel module peut potentiellement utiliser la même suite de répertoire (src/Plugin/ResponsiveBuilder/ColumnExtractor) pour déclarer son propre plugin. Il lui suffira de déclarer un plugin, de créer l'extracteur sous forme de fichier JS et de déclarer une librairie dans le fichier libraries.yml pour ajouter un nouvel extracteur, qui sera disponible dans les settings du module responsive_builder.
- Et surtout, le plugin manager ne lit plus des annotations dans les commentaires, mais inspecte directement les attributs PHP via la réflexion.
Passons au code du manager :
<?php
namespace Drupal\responsive_builder\Service;
use Drupal\Core\Cache\CacheBackendInterface;
use Drupal\Core\Config\ConfigFactoryInterface;
use Drupal\Core\Extension\ModuleHandlerInterface;
use Drupal\Core\Plugin\DefaultPluginManager;
use Drupal\responsive_builder\Attribute\ColumnExtractor;
/**
* Manages ColumnExtractor plugins.
*
* Discovers all classes annotated with #[ColumnExtractor] across all modules.
*/
class ColumnExtractorManager extends DefaultPluginManager {
public function __construct(
\Traversable $namespaces,
CacheBackendInterface $cacheBackend,
ModuleHandlerInterface $moduleHandler,
protected ConfigFactoryInterface $configFactory,
) {
parent::__construct(
// Sub directory to search plugins.
'Plugin/ResponsiveBuilder/ColumnExtractor',
$namespaces,
$moduleHandler,
// Interface that plugin must implements (can be NULL)
NULL,
// PHP attribute class used by plugins.
ColumnExtractor::class,
);
$this->alterInfo('responsive_builder_column_extractor_info');
$this->setCacheBackend($cacheBackend, 'responsive_builder_column_extractor_plugins');
}
/**
* Return plugin by theme.
*
* @param string $theme
* The theme name.
*
* @return array|null
* The plugin definition or NULL if no plugin found.
*/
public function getExtractorByTheme(string $theme): ?array {
$config = $this->configFactory->get('responsive_builder.settings');
$themes_config = $config->get('themes');
$theme_config = $themes_config[$theme] ?? [];
if (isset($theme_config['extractor'])) {
return $this->getDefinitionSafe($theme_config['extractor']);
}
return NULL;
}
/**
* Return options for form select.
*
* @return array<string, string>
* An array with id/label as key/value.
*/
public function getOptions(): array {
$options = [];
foreach ($this->getDefinitions() as $id => $definition) {
$options[$id] = $definition['label'];
}
return $options;
}
/**
* Get plugin definition by id.
*
* @param string $id
* The plugin ID.
*
* @return array|null
* The plugin definition or NULL if no plugin was found.
*/
public function getDefinitionSafe(string $id): ?array {
$definitions = $this->getDefinitions();
return $definitions[$id] ?? NULL;
}
/**
* Geet the Drupal library declared by the plugin.
*
* @param string $pluginId
* The plugin ID.
*
* @return string|null
* The library or NULL if no plugin was found.
*/
public function getLibrary(string $pluginId): ?string {
$definition = $this->getDefinitionSafe($pluginId);
return $definition['library'] ?? NULL;
}
}
Le plus important ici, c'est le constructeur. Il appelle le constructeur de la classe parent (DefaultPluginManager) en lui passant :
- le répertoire dans lequel il trouvera des extracteurs
- la liste des namespaces
- le module handler
- une éventuelle interface qui est utilisée pour tous les plugins de ce type
- la classe correspondante à l'attribut créé auparavant
La méthode getExtractorByTheme(string $theme) permet de récupérer l'extracteur selon la configuration sélectionnée pour le thème passé en paramètre. Au niveau du formulaire de configuration, l'utilisateur peut activer responsive_builder sur un ou plusieurs thèmes, et il va pouvoir indiquer différentes informations :
- le type d'affichage du thème (fixe ou fluide)
- le nombre de colonnes que le thème utilise (généralement 12)
- l'extracteur à employer
C'est cette dernière information qui est passée au ColumnExtractorManager pour récupérer le bon plugin.
Conclusion
Comme vous pouvez le constater, l'utilisation des "Attribute" ressemble à celle des "Annotation", mais elles ne sont pas juste une nouvelle syntaxe. En arrière plan, elles permettent de remettre Drupal dans le chemin d'un éco-système moderne, exploitant au mieux l'utilisation de PHP et améliorant les performances générales du système.
Les Attributes ne sont pas qu’une évolution syntaxique. Ils marquent un tournant dans Drupal : moins de magie, plus de PHP natif, plus de robustesse. On passe d’un système basé sur du parsing de commentaires à un système basé sur du code réel, typé et outillé.
Même si beaucoup de modules de contribution sont encore basés sur les annotations Doctrine, l'utilisation des attributs en PHP devrait à terme prendre le dessus et les annotations finiront par disparaitre.
Faut-il migrer ?
Non, pas forcément. Drupal reste compatible avec les annotations, et beaucoup de modules contrib les utilisent encore. Mais pour tout nouveau développement, les Attributes sont clairement la voie à privilégier.