Una alternativa moderna a los Hooks de Drupal

Edificio moderno

En Drupal 8 surgió un patrón en el que los hooks se implementaban en un archivo .module tradicional y luego se transferían rápidamente a una clase a través de una llamada de servicio o se instanciaban a través del servicio 'class_resolver' el cual usa la clase \Drupal\Core\DependencyInjection\ClassResolver. Esto es algo que se planteó para en un futuro (que sería Drupal 9) reemplazar todos los hooks por algo más moderno, los eventos de Symfony.

Lo que hemos podido ver con el paso del tiempo, es que todavía no se han reemplazado los hooks por otro sistema mejor, y es que el actual tiene algunos problemas que pueden complicarnos la vida si tenemos que desarrollar sistemas complejos o tenemos muchísimos módulos que interactúan entre sí, y puede llevar a un conflicto en el nombre de algunos hooks.

/**
* Implements hook_entity_presave().
*/
function content_moderation_entity_presave(EntityInterface $entity) {
 return \Drupal::service('class_resolver')
   ->getInstanceFromDefinition(EntityOperations::class)
   ->entityPresave($entity);
}

Aunque de cara a los desarrolladores este sistema no ha sufrido cambios de manera pública, internamente el sistema de hooks ha cambiado bastante, hasta el punto de que en Drupal 9.4 se unificó casi todo el sistema bajo el servicio Module Handler, al hacer esto permite a los desarrolladores utilizar los decoradores de servicios para alterar el funcionamiento de ese servicio.

Esa mejora, permite que proyectos contribuidos amplíen las capacidades de ese servicio, y de esa manera nació Hux

¿Y qué es lo que nos permite realizar Hux?

Usando el módulo contribuido Hux podemos crear los hooks fuera del archivo .module y usar una clase para ese propósito manteniendo edemas compatibilidad con el sistema actual. Esto nos permite utilizar la inyección de servicios correctamente y por fin olvidarnos de la clase \Drupal para los hooks.

Requisitos de Hux

Aunque esto está muy bien, hay unos requisitos, y estos son muy básicos y hoy en día cualquiera que use Drupal los cumple. Lo primero, es necesario usar Drupal 9.4 o superior, esto debería ser sencillo porque Drupal 9 no tiene soporte desde el 1 de noviembre del 2023, de modo que deberíamos tener ya todos nuestros sitios actualizados a Drupal 10. El segundo requisito es usar PHP 8.0 o superior, pero esto también deberíamos cumplirlo fácilmente porque Drupal 10 requiere PHP 8.1 o superior.

Como vemos hoy en día son unos requisitos muy sencillos de cumplir, y es que cualquier sitio que esté en Drupal 10 los cumple, de modo que podríamos decir que el requisito real, es tener Drupal 10.

Como implementar un hook

Y como se consigue utilizar hooks dentro de clases? pues veamos un pequeño ejemplo de cómo se puede hacer:

namespace Drupal\my_module\Hooks;
use Drupal\hux\Attribute\Hook;
/**
* Sample hooks.
*/
final class SampleHooks {
 #[Hook('entity_access')]
 public function myEntityAccess(EntityInterface $entity, string $operation, AccountInterface $account): AccessResult {
   return AccessResult::neutral();
 }
 
}

Como podemos ver se crea una clase con el namespace Drupal\my_module\Hooks, el cual hay que respetar, es decir, tiene que estar dentro de la carpeta src/Hooks de nuestro modulo. Después creamos un método, le llamamos como deseemos, y fijaros como se utiliza un atributo para indicar que ese método pertenece al hook "entity_access". Y listo, no tenemos que hacer nada más, de esa manera tenemos nuestro hook implementado en una clase y no en el archivo .module.

Implementar un alter

En el primer ejemplo hemos visto cómo podemos implementar el hook "entity_access", pero ese es un hook normal, pero en Drupal también tenemos los alter, estos son sencillos de implementar con Hux:

namespace Drupal\my_module\Hooks;
use Drupal\hux\Attribute\Alter;
/**
* Sample hooks.
*/
final class SampleHooks {
 #[Alter('user_format_name')]
 public function myCustomAlter(string &$name, AccountInterface $account): void {
   $name .= ' altered!'; 
 }
 
}

En el ejemplo estamos implementando el hook hook_user_format_name_alter, como podemos ver esta vez no se usa el atributo "Hook" si no que es "Alter", además, no tenemos que añadir "hook" ni "alter" al inicio o al final respectivamente.

Reemplazar hooks

Pongámonos en el supuesto de que necesitamos evitar la elección de otro hook, de album modulo ya sea de la comunidad o del core. Pues tambien podemos realizar esta acción, la cual nos puede facilitar bastante el trabajo en algunas ocasiones.

namespace Drupal\my_module\Hooks;
use Drupal\hux\Attribute\ReplaceOriginalHook;
/**
* Sample hooks.
*/
final class SampleHooks {
 #[ReplaceOriginalHook(hook: 'entity_access', moduleName: 'media')]
 public function myEntityAccess(EntityInterface $entity, string $operation, AccountInterface $account): AccessResult {
   return AccessResult::neutral();
 }
 
}

Vemos que esta vez tenemos que usar ReplaceOriginalHook y además indicar 2 parámetros, el hook que reemplazamos y de que modulo, de modo que podemos entender que en el ejemplo estamos reemplazando el hook_entity_access implementado por el módulo Media.

Pero esto no es todo, y es que de esta manera reemplazamos la ejecución completa del hook, pero que sucede si lo que nos interesa es comprobar su resultado y en base a ello nosotros realizar alguna acción? Pues que también existe esa posibilidad:

namespace Drupal\my_module\Hooks;
use Drupal\hux\Attribute\ReplaceOriginalHook;
use Drupal\hux\Attribute\OriginalInvoker;
/**
* Sample hooks.
*/
final class SampleHooks {
 #[ReplaceOriginalHook(hook: 'entity_access', moduleName: 'media')]
 public function myEntityAccess(#[OriginalInvoker] callable $originalInvoker,, EntityInterface $entity, string $operation, AccountInterface $account): AccessResult {
   $originalResult = $originalInvoker($entity, $operation, $account);
   return AccessResult::neutral();
 }
 
}

Si revisamos el código podemos ver que el primer parámetro que recibimos ahora es un callable con el atributo "OriginalInvoker", el cual podemos ejecutar como se observa para tener el resultado, y con eso ya trabajar para devolver ese mismo resultado o en caso de que no sea lo que deseemos, realizar las operaciones que deseemos.

Prioridad en la ejecución

Uno de los mayores problemas que he tenido yo en Drupal con los hooks, ha sido la prioridad de ejecución de los mismos, y es que en algunas ocasiones he necesitado ejecutar mis hooks después de alguno concreto. Esto es fácilmente solucionable con Hux, y es que a los hooks (no a los alter ni al reemplazar) podemos indicar la prioridad que deseemos.

namespace Drupal\my_module\Hooks;
use Drupal\hux\Attribute\Hook;
/**
* Sample hooks.
*/
final class SampleHooks {
 #[Hook('entity_access', priority: 100)]
 public function myEntityAccess(EntityInterface $entity, string $operation, AccountInterface $account): AccessResult {
   return AccessResult::neutral();
 }
 
}

En el atributo hay un parámetro llamado "priority" (por defecto es 0), al cual podemos indicarle un número, ese número es la prioridad y cuanto más alto sea, antes se ejecutará ese hook.

Inyección de dependencias

Hemos visto multiples opciones de lo que Hux nos permite, pero una de las más importantes en la POO es olvidarnos de las funciones y utilizar objetos, de esta manera además podemos usar la inyección de dependencias.

Veamos ahora un ejemplo para darnos cuenta de lo sencillo que es:

namespace Drupal\my_module\Hooks;
use Drupal\hux\Attribute\Hook;
use Drupal\Core\DependencyInjection\ContainerInjectionInterface;
/**
* Sample hooks.
*/
final class SampleHooks implements ContainerInjectionInterface {
 public function __construct(
   private EntityTypeManagerInterface $entityTypeManager,
 ) {
 }
 
 public static function create(ContainerInterface $container): static {
   return new static(
     $container->get('entity_type.manager'),
   );
 }
 
 #[Hook('entity_access')]
 public function myEntityAccess(EntityInterface $entity, string $operation, AccountInterface $account): AccessResult {
   // Do something with dependencies.45
   $this->entityTypeManager->loadMultiple(...);
   return AccessResult::neutral();
 }
 
}

No tiene mucho más misterio de lo habitual, implementar la interfaz ContainerInjectionInterface, el método "create" como se hace en multiples puntos de Drupal y de ahi obtener los servicios que necesitemos.

Clases sin auto-discovery

Al principio del artículo se ha comentado que es necesario que las clases de PHP estén en en src/Hooks. Bueno, esto no es cierto, lo que pasa es que añade comodidad ya que Hux buscará las clases con el namespace Drupal\my_module\Hooks. Pero en caso de que queramos otra ubicación para nuestras clases de hooks, existe otra opción, y es declararlas como un servicio con un tag concreto:

services:
 my_module.my_hooks:
   class: Drupal\my_module\MyHooks
   arguments:
     - '@entity_type.manager'
   tags:
     - { name: hooks, priority: 100 }

¿Y con los preprocess que sucede?

En este caso, de momento no se debe usar Hux para los preprocess, de hecho hay una incidencia para evitar su uso, de modo que los preprocess deberán continuar como siempre, en los .module o mejor, en los .theme de los theme.

¿Y esto como afecta al core?

El sistema actual funciona exactamente igual cuando instalemos Hux, de modo que es 100% compatible. De hecho, este método ha gustado tanto que se está desarrollando una prueba de concepto en una tarea del core de Drupal y me da la impresión de que gusta más que los eventos de Symfony, y que será la opción que reemplace a los actuales hooks, ya que es más moderna y fácil de implementar que cambiar el sistema completo de hooks a los eventos de Symfony.

Tambien existe una meta incidencia para implementar este sistema en el core donde hay unos comentarios interesantes, por ejemplo, uno donde se menciona que la idea era tener esto listo para Drupal 10.2, pero lamentablemente no han llegado, pero confiemos que este nuevo sistema esté implementado antes de Drupal 11.0.

Resumen

  • Para que las clases en los directorios Hooks/ sean descubiertas, necesitan al menos un método público con un atributo Hux. Sin un atributo, estas clases/archivos serán ignorados.
  • Evitar su uso para los preprocess
  • Una vez que el contenedor es consciente de una clase hooks, se pueden añadir más hooks sin borrar la caché.
  • Cada módulo puede tener tantas clases hook como se desee, nombradas de cualquier manera.
  • ¡Un hook puede ser implementado múltiples veces por módulo!
  • Un método hook puede tener cualquier nombre.
  • Una clase hook no tiene interfaz. 
  • El uso de inyección de contenedor es completamente opcional. Alternativamente, se puede conseguir DI declarando un servicio manualmente.
  • El rendimiento es prioritario. Hux actúa como un decorador para el núcleo ModuleHandler. Después del descubrimiento, sólo hay una pequeña sobrecarga en tiempo de ejecución.
  • Funciona con la mayoría de los hooks. hook_theme es un ejemplo notable de un hook que no funciona, junto con los preprocesadores de temas. Aunque los preprocesadores son menos hooks y más análogos a callbacks.
Comparte este artículo:
Publicado por Borja
Image

Me metí en la aventura de Drupal con la versión 6, y aquí estoy, 10 años después, escribiendo articulos y haciendo videos sobre Drupal, quien me lo iba a decir. Aunque he probado otros framworks y cms, me quedo con Drupal de lejos, pero Symfony y Django estan entre mis favoritos. Aficionado a la montaña, la bicicleta, y el comer, de eso que no falte.