Aller au contenu principal

Nouvel attribut #[Hook('...')] dans Drupal 11

Image
Par Thierry V le

Les hooks, c'est un peu le système d'event dispatcher du pauvre... Pendant très longtemps, Drupal a utilisé ce système pour intercepter des processus et permettre aux développeurs d'influencer des données à travers des méthodes nommées spécifiquement. 

À l'origine, on a un module. Appelons le "my_module". Ce module peut déclarer un fichier .module, donc dans mon exemple, my_module.module. Ce fichier, c'était un peu le "fourre-tout" du module. On y ajoutait tous les hooks dont le module avait besoin. Et des hooks, il y en a... hook_form_alter, hook_FORM_ID_form_alter, hook_theme, hook_entity_save, hook_entity_info, et j'en passe, et des meilleurs... Bref... c'est là qu'on implémentait nos hooks en remplaçant "hook" par le nom du module. Donc "hook_theme(...)" devient "my_module_theme(...)". Chaque hook possède ses propres arguments, et on reproduit la bonne signature de fonction pour pouvoir pleinement intercepter le processus qu'on désire.

Mais ça, c'était avant (ou presque...). A partir de Drupal 11.1, le système de hooks a commencé à migrer. Tous les hooks, malheureusement, ne sont pas encore pris en charge. Et si mes souvenirs sont bons, les hook inclus dans des thèmes (fichier my_theme.theme) n'héritent pas de ce changement.

Ce changement, c'est l'attribut Hook (\Drupal\Core\Hook\Attribute\Hook). Lui, c'est une petite révolution dans le monde Drupal. Jusqu'ici, on devait entasser des fonctions dans un fichier module, on ne pouvait pas bénéficier de l'injection de services, on devait très précisément respecter la nomenclature {module_name}_{hook}(...)... Pire encore, les fichiers de modules étaient chargés dès le départ, sans se soucier de si ils étaient réellement nécessaires ou pas. Mais là, ce petit attribut "Hook", il change la donne.

Alors bien entendu, comme je l'ai cité plus haut, ce n'est pas utilisable dans TOUS les cas de figure, mais c'est quand même super pratique. Imaginez : vous kiffez faire de la POO, développer avec la bonne logique, et là, on vous offre un peu le graal. Avec cette petite annotation, vous pouvez presque vous affranchir du fichier .module, tout ça avec une façon de coder qui vous convient... C'est quand même quelque chose, non ?

Tout commence par un namespace : Drupal\{module_name}\Hooks

La dedans, c'est tout simple. Vous créez une ou plusieurs classes pour implémenter les hooks dont vous avez besoin. ça permet plein de choses... 

  • Regrouper les implémentations par thématique, 
  • par fonctionnalité, 
  • par logique... 

C'est vous qui décidez.

Perso, j'ai opté pour une logique assez basique : 

src\Hooks
- EntityHooks.php
- PreprocessHooks.php
- ThemeHooks.php
- etc...

Ensuite, bah... on implémente nos méthodes en utilisant la bonne notation au niveau de l'attribut Hook : 

<?php

declare(strict_types=1);

namespace Drupal\my_module\Hook;

use Drupal\Core\Entity\EntityInterface;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\Hook\Attribute\Hook;
use Symfony\Component\DependencyInjection\ContainerInterface;

/**
 * Theme hooks implementation for banner blocks module.
 */
class EntityHooks {

  /**
   * Constructor.
   */
  public function __construct(
    protected ConfigFactoryInterface $configFactory,
    protected EntityTypeManagerInterface $entityTypeManager,
  ) {
  }

  /**
   * Container factory method.
   *
   * @param Symfony\Component\DependencyInjection\ContainerInterface $container
   *   The container interface.
   *
   * @return static
   */
  public static function create(ContainerInterface $container): self {
    return new self(
      $container->get('config.factory'),
      $container->get('entity_type.manager'),
    );
  }

  /**
   * Update entity.
   *
   * @param \Drupal\Core\Entity\EntityInterface $entity
   *   The newly to-insert or to-update entity.
   */
  #[Hook('entity_insert')]
  #[Hook('entity_update')]
  public function entityUpdate(EntityInterface $entity) {
    $this->updateMyEntity($entity);
  }

  /**
   * Apply process when entity will be inserted/updated.
   *
   * @param \Drupal\Core\Entity\EntityInterface $entity
   *   The inserted/updated entity.
   */
  private function updateMyEntity(EntityInterface $entity) {
    // Do something.
  }

  /**
   * Implements hook_entity_delete().
   *
   * @param \Drupal\Core\Entity\EntityInterface $entity
   *   The deleted entity.
   */
  #[Hook('entity_delete')]
  public function entityDelete(EntityInterface $entity) {
    $this->deleteMyEntity($entity);
  }
  
  /**
   * Apply process when entity will be deleted.
   *
   * @param \Drupal\Core\Entity\EntityInterface $entity
   *   The deleted entity.
   */
  private function deleteMyEntity(EntityInterface $entity) {
    // Do something.
  }
}

Quelques points intéressants

L'exemple est très simple, mais on peut voir des informations assez intéressantes.

  1. J'applique la même méthode sur 2 hooks simultanément (entity_insert, entity_update)
  2. J'injecte des services dans ma classe, pour pouvoir les utiliser dans les méthodes utilitaires.

Petit détail important concernant l'injection des services

Il y a un petit détail à noter. Si vous injectez vos propres services, il arrive parfois qu'ils ne soient pas reconnus, pour la simple et bonne raison qu'ils n'ont pas été déclarés explicitement dans le fichier service.yml de votre module.

J'ai été confronté plusieurs fois à cette problématique. Un service, pour être utilisé à certains moments assez tôt dans le bootstrap Drupal (typiquement sur les hooks de thème), doit être déclaré comme suivant : 

  my_module.my_manager:
    class: Drupal\my_module\Service\MyManager
    arguments: 
      - '@entity_type.manager'
      - '@entity_display.repository'
      - '@entity_type.bundle.info'
      - '@module_handler'
      - '@event_dispatcher'
      - '@config.factory']
  Drupal\my_module\Service\MyManager: '@my_module.my_manager'
  Drupal\my_module\Service\MyManagerInterface:
    alias: my_module.my_manager

Non seulement on déclare notre service comme d'habitude, mais on y rajoute : 

La classe correspondante au service : 

Drupal\my_module\Service\MyManager: '@my_module.my_manager'

Un éventuel alias pour l'interface, pour autant que le service implémente une interface.

  Drupal\my_module\Service\MyManagerInterface:
    alias: my_module.my_manager

Ces petits ajouts évitent que le service ne soit pas reconnus dans certains cas (rapport au bootstrap Drupal) et de se retrouver avec des erreurs lorsqu'on déclare l'interface plutôt que le service dans la signature du constructeur.

Revenons à l'attribut Hook

C'est en faisant des recherches un peu plus approfondies que j'ai découvert 2-3 petites choses assez intéressantes.

L'attribut doit forcément indiquer le hook qu'il doit intercepter. Par contre, il existe des paramètres qu'on peut ajouter.

  • method : Le nom de la méthode qui sera utilisée. La doc l'indique d'ailleurs clairement : si l'attribut #[Hook(...)] accompagne une méthode, ce paramètre n'est pas nécessaire. Si l'attribut #[Hook(...)] est déclaré au niveau de la classe, c'est par défaut la méthode __invoke() qui sera appelée, mais c'est dans ce cas qu'on pourrait indiquer un nom de méthode bien spécifique.
  • module : Permet d'indiquer le nom du module pour qui est cette implémentation.
  • order : une instance de OrderInterface. Order::First, Order::Last ou encore une instance de type OrderBefore ou OrderAfter, qui prend en paramètre un tableau de noms de modules.

Et c'est là que c'est intéressant, car il est possible de modifier l'ordre d'exécution des hooks déclarés avec attribut, et ça sans devoir implémenter hook_modules_implements_alter.

Compatibilité avec les versions antérieures

Cerise sur le gâteau, un autre attribut #[LegacyHook] permet de taguer une fonction de hook dans le fichier .module pour lui indiquer qu'elle est la "racine" dans le cas d'une version antérieure à Drupal 11.1. L'implémentation de la fonction de hook se fera alors en déclarant la classe Hook en tant que service :

services:
  Drupal\my_module\Hook\EntityHooks:
    class: Drupal\my_module\Hook\EntityHooks
    autowire: true

puis en appelant ce service dans la fonction de hook et en exécutant la méthode correspondante : 

#[LegacyHook]
function my_module_entity_insert(EntityInterface $entity) {
  my_module_update_entity($entity);
}

#[LegacyHook]
function my_module_entity_update(EntityInterface $entity) {
  my_module_update_entity($entity);
}

function my_module_update_entity(EntityInterface $entity) {
  \Drupal::service(EntityHooks::class)->entityUpdate($entity);
}

#[LegacyHook]
function my_module_entity_delete(EntityInterface $entity) {
  \Drupal::service(EntityHooks::class)->entityDelete($entity);
}

Et voilà !!

N'hésitez pas à en user et abuser ;-)