Skip to content

How-to guideFor module developersDrupal 11

Convert procedural hooks to classes

Since Drupal 11.1, hooks are class methods marked with the #[Hook] attribute, with services injected through the constructor; the procedural my_module_node_insert() style in a .module file is legacy. This page is for an existing module that still uses that style. It is the migration job: move the module’s hooks into classes, keep compatibility with older Drupal if you need it, and handle the cases (ordering, removal, and hooks that must stay procedural) that decide whether the conversion goes smoothly.

If you are writing a new module, you do not convert anything: write hooks as classes from the start, as the first module tutorial and the hooks and events concept page do.

A module whose hook implementations live in autowired classes under src/Hook/, with an empty (or near-empty) .module file. Worth doing because hook classes get constructor injection, real types, IDE navigation, and per-hook ordering; and because core itself has converted (the node module’s implementations now live in a set of focused classes under core/modules/node/src/Hook/, with node.module reduced to a handful of plain API functions).

  • Hooks and events for what hooks are and when they fire; this page only covers the implementation style.
  • A Drupal 11.1+ site. Ordering attributes shown below need 11.2+.
  1. Audit which hooks can move. Most can; three groups cannot, verbatim from the \Drupal\Core\Hook\Attribute\Hook attribute’s own documentation. The legacy meta hooks hook_hook_info() and hook_module_implements_alter() stay procedural; ordering moves to the order parameter and #[ReorderHook] (see Variations). Install and update hooks stay procedural in .install files, because they run at moments (mid-install, mid-update) when the module’s services cannot be relied on:

    And hooks implemented by themes must remain procedural; if your module ships a theme, its .theme file does not convert.

  2. Create a class under src/Hook/ and move implementations onto methods. The class needs no registration: any class in that directory using #[Hook] is discovered and registered as an autowired service automatically, so the constructor can request services by type (with #[Autowire(service: ...)] where a type hint is ambiguous):

    web/modules/custom/docs_snippets/modules/module_howtos_a/src/Hook/ModuleHowtosAHooks.php
    <?php
    declare(strict_types=1);
    namespace Drupal\module_howtos_a\Hook;
    use Drupal\Core\Hook\Attribute\Hook;
    use Drupal\Core\Hook\Order\Order;
    use Drupal\node\NodeInterface;
    use Drupal\user\UserInterface;
    use Psr\Log\LoggerInterface;
    use Symfony\Component\DependencyInjection\Attribute\Autowire;
    /**
    * Hook implementations for the Module how-tos A module.
    *
    * Any class in a module's src/Hook directory with #[Hook] attributes is
    * discovered automatically and registered as an autowired service, so the
    * constructor can ask for whatever the implementations need.
    */
    class ModuleHowtosAHooks {
    /**
    * Constructs the hook class.
    *
    * @param \Psr\Log\LoggerInterface $logger
    * The module's logger channel. The interface alone is ambiguous (every
    * channel implements it), so the Autowire attribute names the service.
    */
    public function __construct(
    #[Autowire(service: 'logger.channel.module_howtos_a')]
    protected readonly LoggerInterface $logger,
    ) {}
    /**
    * Implements hook_ENTITY_TYPE_insert() for node entities.
    */
    #[Hook('node_insert')]
    public function nodeInsert(NodeInterface $node): void {
    $this->logger->notice('New content: %title.', ['%title' => $node->label()]);
    }
    /**
    * Implements hook_user_login().
    *
    * The order parameter runs this before every other module's
    * implementation; OrderBefore and OrderAfter order relative to
    * specific modules instead.
    */
    #[Hook('user_login', order: Order::First)]
    public function userLogin(UserInterface $account): void {
    $this->logger->info('%name logged in.', ['%name' => $account->getAccountName()]);
    }
    }

    The conversion per hook is mechanical: function my_module_node_insert($node) becomes a method with #[Hook('node_insert')], the attribute carrying the hook name without the module prefix. Method names are yours to choose. The body changes in one way only: \Drupal::service() calls become constructor-injected properties. Group related hooks into several classes the way node does (NodeEntityHooks, NodeFormHooks, NodeThemeHooks, …) rather than one giant class.

    Four hooks allow only one implementation per module, so keep each in a single place: hook_library_info_build(), hook_mail(), hook_help(), and hook_node_update_index(). Everything else may have multiple implementations, even within one module; that is new with the attribute style, and useful for splitting a sprawling hook_form_alter() by concern.

  3. Delete the procedural implementations, unless the module must also run on Drupal versions that do not understand #[Hook] (a contrib module supporting Drupal 10). In that case keep a procedural stub for each hook and mark it with \Drupal\Core\Hook\Attribute\LegacyHook:

    web/modules/custom/docs_snippets/modules/module_howtos_a/module_howtos_a.module
    <?php
    /**
    * @file
    * Legacy hook implementations kept for older Drupal versions.
    */
    declare(strict_types=1);
    use Drupal\Core\Hook\Attribute\LegacyHook;
    use Drupal\module_howtos_a\Hook\ModuleHowtosAHooks;
    use Drupal\node\NodeInterface;
    /**
    * Implements hook_ENTITY_TYPE_insert() for node entities.
    */
    #[LegacyHook]
    function module_howtos_a_node_insert(NodeInterface $node): void {
    // Delegate so the logic exists exactly once, in the class. On Drupal
    // versions that understand #[Hook], the LegacyHook attribute stops this
    // function from running a second time; on older versions, only this
    // function runs and the class resolver loads the class as the service
    // registered in module_howtos_a.services.yml.
    \Drupal::classResolver(ModuleHowtosAHooks::class)->nodeInsert($node);
    }

    On 11.1+, the attribute tells Drupal the class method already covers this hook, so the function never runs and nothing executes twice. On older versions, the attribute class is unknown but inert (attributes only act when something reflects on them), so the function runs and delegates. Either way the logic exists once.

    Drupal 10 does not discover hook classes, so the class resolver can only build one with its constructor arguments if it is a service. Register each hook class as an autowired service in the module’s *.services.yml:

    web/modules/custom/docs_snippets/modules/module_howtos_a/module_howtos_a.services.yml
    services:
    logger.channel.module_howtos_a:
    parent: logger.channel_base
    arguments:
    - 'module_howtos_a'
    # Registered explicitly so Drupal 10, which does not discover hook classes,
    # can build it. On 11.1+ this definition replaces the automatic one.
    Drupal\module_howtos_a\Hook\ModuleHowtosAHooks:
    class: Drupal\module_howtos_a\Hook\ModuleHowtosAHooks
    autowire: true

    A module that reorders hooks with hook_module_implements_alter() keeps that function for Drupal 10 and marks it with #[LegacyModuleImplementsAlter] Added in 11.2, so the order parameters take over on newer versions.

  4. Rebuild: ddev drush cache:rebuild. Hook discovery happens at container build time, so every change to hook attributes or class constructors needs a rebuild before it takes effect.

  • ddev drush cache:rebuild completes without container build errors (a typo in an #[Autowire] service ID fails here, not silently).
  • Trigger a converted hook: create a node, then check Administer → Reports → Recent log messages/admin/reports/dblog for the “New content” entry written by nodeInsert(). With a #[LegacyHook] stub in place, confirm the entry appears once, not twice.
  • For a full conversion, your .module file now contains no function my_module_HOOK() implementations, only the procedural API functions you chose to keep.

Order an implementation relative to others Added in 11.2

Section titled “Order an implementation relative to others ”

When several modules implement the same hook, the order parameter positions yours without weight games: Order::First and Order::Last (an enum implementing \Drupal\Core\Hook\Order\OrderInterface), or relative to specific modules with \Drupal\Core\Hook\Order\OrderBefore and \Drupal\Core\Hook\Order\OrderAfter:

#[Hook('form_alter', order: new OrderBefore(['content_translation']))]

The userLogin() method in the class above uses order: Order::First.

Reorder or remove other modules’ implementations Added in 11.2

Section titled “Reorder or remove other modules’ implementations ”

\Drupal\Core\Hook\Attribute\ReorderHook repositions an implementation you do not own: you name the hook, the target class and method, and the new order. Core’s workspaces module uses it to push content moderation’s hook_entity_presave() ahead of its own. \Drupal\Core\Hook\Attribute\RemoveHook goes further and unregisters the target implementation entirely:

#[RemoveHook('form_alter', class: OtherModuleHooks::class, method: 'formAlter')]

Both attributes work regardless of which class or method you place them on; they act on their target, not their location.

Implement a hook on behalf of another module

Section titled “Implement a hook on behalf of another module”

The attribute’s module parameter registers the implementation as if it belonged to that module, which matters for hooks invoked “for” a specific module and for ordering semantics: #[Hook('form_alter', module: 'other_module')].

drupal-rector moves procedural hook implementations into #[Hook] classes. Review the generated classes, then inject the services their bodies still call statically.

For a hook implementation with heavy dependencies, put the attribute on the class and implement __invoke(); #[Hook('cron')] on the class is the whole wiring. The method parameter variant (#[Hook('cron', method: 'runMaintenance')]) also exists for grouping without renaming methods.

Was this page helpful?

Drupal is a registered trademark of Dries Buytaert.