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).
Prerequisites
Section titled “Prerequisites”- 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+.
-
Audit which hooks can move. Most can; three groups cannot, verbatim from the
\Drupal\Core\Hook\Attribute\Hookattribute’s own documentation. The legacy meta hookshook_hook_info()andhook_module_implements_alter()stay procedural; ordering moves to theorderparameter and#[ReorderHook](see Variations). Install and update hooks stay procedural in.installfiles, because they run at moments (mid-install, mid-update) when the module’s services cannot be relied on:hook_install(),hook_uninstall()hook_install_tasks(),hook_install_tasks_alter()hook_update_N(),hook_update_dependencies(),hook_update_last_removed()hook_post_update_NAME(),hook_removed_post_updates()-
hook_schema()
And hooks implemented by themes must remain procedural; if your module ships a theme, its
.themefile does not convert. -
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 <?phpdeclare(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(), andhook_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 sprawlinghook_form_alter()by concern. -
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_basearguments:- '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\ModuleHowtosAHooksautowire: trueA 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 theorderparameters take over on newer versions. -
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.
Verify
Section titled “Verify”ddev drush cache:rebuildcompletes 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/dblogfor the “New content” entry written bynodeInsert(). With a#[LegacyHook]stub in place, confirm the entry appears once, not twice. - For a full conversion, your
.modulefile now contains nofunction my_module_HOOK()implementations, only the procedural API functions you chose to keep.
Variations
Section titled “Variations”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')].
Automating the conversion
Section titled “Automating the conversion”drupal-rector moves procedural hook implementations into #[Hook] classes. Review the generated classes, then inject the services their bodies still call statically.
One class per hook
Section titled “One class per hook”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.
Related
Section titled “Related”- Hooks and events for choosing between hooks and event subscribers.
- Replace deprecated and static API calls for the
\Drupal::service()to injection conversion the hook bodies need. - Services and dependency injection for what the autowired constructor can ask for.
- Build and alter forms and Create, load, and query entities show
#[Hook]classes in working context. the Hooks topicon api.drupal.org.
Was this page helpful?
Drupal is a registered trademark of Dries Buytaert.