In December 2024, Drupal 11.1 introduced a new, object-oriented way to implement hooks! It is now the approach recommended by core, and almost every hook is covered.
Update, August 6, 2026: article revised for Drupal 11.4. Since it was first published, preprocess functions, requirements and theme hooks have moved to object-oriented code, two names changed in 11.2, and the list of hooks that must stay procedural has shrunk considerably.
In practice, all you need is to create a MesHooks.php file (one or more files, the name is up to you) in the src/Hook folder of your module (it can be in a subfolder, but the base folder must be Hook for Drupal to detect it).
So, to implement HOOK_form_alter, here is how it goes:
I create a mon_module/src/Hook/FormHooks.php file with the following content:
<?php
declare(strict_types=1);
namespace Drupal\mon_module\Hook;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Hook\Attribute\Hook;
/**
* Provides hook implementations for Form alterations.
*/
class FormHooks {
/**
* Alters a Drupal form before it is rendered.
*
* @param array $form
* An associative array containing the structure of the form.
* @param \Drupal\Core\Form\FormStateInterface $form_state
* An object containing the current state of the form.
* @param string $form_id
* The unique identifier of the form being altered.
*/
#[Hook('form_alter')]
public function formAlter(array &$form, FormStateInterface $form_state, string $form_id): void {
...
}
/**
* Alters the programme bundle edit and add forms
*
* @param array $form
* The form structure to be altered.
* @param \Drupal\Core\Form\FormStateInterface $form_state
* The current state of the form.
* @param string $form_id
* The unique identifier of the form.
*/
#[Hook('form_node_programme_edit_form_alter')]
#[Hook('form_node_programme_form_alter')]
public function formProgrammeAlter(array &$form, FormStateInterface $form_state, string $form_id): void {
...
}
}It is the PHP attribute
#[Hook('form_alter')]that tells Drupal which hook is implemented by the method that follows (whose name is up to you).
You can also use the same method for several hooks: here I implement HOOK_form_FORM_ID_alter for the node add and edit forms of my "programme" content type.
#[Hook('form_node_programme_edit_form_alter')]
#[Hook('form_node_programme_form_alter')]
public function formProgrammeAlter(array &$form, FormStateInterface $form_state, string $form_id): void {
...
}Dependency injection, a bonus
This is the main benefit of the whole thing: your hook classes are automatically registered as autowired services, with the class name as the service ID. Nothing to declare in your mon_module.services.yml, you can inject your dependencies directly in the constructor instead of calling \Drupal::service():
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\Session\AccountProxyInterface;
class FormHooks {
public function __construct(
protected readonly AccountProxyInterface $currentUser,
protected readonly EntityTypeManagerInterface $entityTypeManager,
) {}
#[Hook('form_alter')]
public function formAlter(array &$form, FormStateInterface $form_state, string $form_id): void {
if ($this->currentUser->hasPermission('administer nodes')) {
...
}
}
}Testable hooks, with real injected dependencies. And if autowiring isn't enough, nothing stops you from declaring the service by hand in your services.yml.
Other ways to place the attribute
The attribute can also be placed on the class, specifying the method concerned:
#[Hook('user_cancel', method: 'userCancel')]
class Hooks {
public function userCancel(...) {}
}or on an invokable class, whose __invoke() method is then the implementation:
#[Hook('user_cancel')]
class Hooks {
public function __invoke(...) {}
}Finally, a module can implement a hook on behalf of another module, using the module parameter:
#[Hook('form_alter', module: 'autre_module')]Good news along the way: a single module can implement the same hook several times, in different classes. Handy for adding a form_alter without touching existing code. Only four hooks allow just one implementation per module: hook_library_info_build(), hook_mail(), hook_help() and hook_node_update_index().
Controlling execution order (Drupal 11.2)
hook_module_implements_alter() is no longer the only way to change the order in which hooks run; the attribute now accepts an order parameter:
use Drupal\Core\Hook\Order\Order;
use Drupal\Core\Hook\Order\OrderAfter;
use Drupal\Core\Hook\Order\OrderBefore;
#[Hook('entity_type_alter', order: Order::First)]
#[Hook('entity_type_alter', order: Order::Last)]
#[Hook('entity_type_alter', order: new OrderBefore(['foo']))]
#[Hook('entity_type_alter', order: new OrderAfter(['foo']))]And two attributes let you act on other modules' implementations without touching them: #[ReorderHook] to reorder them, #[RemoveHook] to prevent them from running altogether:
use Drupal\Core\Hook\Attribute\RemoveHook;
use Drupal\Core\Hook\Attribute\ReorderHook;
// Run content_moderation's entity_presave before the workspaces one.
#[ReorderHook('entity_presave',
class: ContentModerationHooks::class,
method: 'entityPresave',
order: new OrderBefore(['workspaces'])
)]
// Do not run layout_builder's hook_help.
#[RemoveHook('help',
class: LayoutBuilderHooks::class,
method: 'help'
)]The change record: https://www.drupal.org/node/3497308
The end of the .module file?
Almost. As stated in the change record, not every hook can be implemented this way. Some must stay procedural, but the list has shrunk considerably since 11.1 came out. Here is the current one, as of Drupal 11.4:
- hook_hook_info()
- hook_module_implements_alter()
- hook_install()
- hook_install_tasks()
- hook_install_tasks_alter()
- hook_post_update_NAME()
- hook_removed_post_updates()
- hook_schema()
- hook_uninstall()
- hook_update_dependencies()
- hook_update_last_removed()
- hook_update_N()
In short: whatever deals with installing and updating (the content of your .install file) stays procedural, plus two "meta" hooks. Everything else can move to a class.
Preprocess functions, since Drupal 11.2
Modules' hook_preprocess() and hook_preprocess_HOOK() can be implemented in object-oriented code since Drupal 11.2 (change record):
#[Hook('preprocess_node')]
public function preprocessNode(array &$variables): void {
...
}This solves the issue Alan and I ran into at the end of 2024, while testing upgrades to Drupal 11.1: HOOK_preprocess_ENTITY_TYPE had to stay procedural back then, and we had to keep the service definition and the procedural hook to call the class. That is no longer needed. The only limitation: a single implementation per module for a given preprocess.
hook_requirements(), deprecated since Drupal 11.3
It is replaced by three separate entry points (change record), all compatible with object-oriented code:
- hook_runtime_requirements() and hook_runtime_requirements_alter() for the site status report;
- hook_update_requirements() and hook_update_requirements_alter() for pre-update checks;
- a Drupal\mon_module\Install\Requirements\MonModuleRequirements class implementing InstallRequirementsInterface for install-time checks.
namespace Drupal\mon_module\Hook;
use Drupal\Core\Extension\Requirement\RequirementSeverity;
use Drupal\Core\Hook\Attribute\Hook;
class RequirementsHooks {
#[Hook('runtime_requirements')]
public function runtime(): array {
$requirements = [];
// ...
return $requirements;
}
}If you need to keep a procedural hook_requirements() for earlier versions, mark it with the #[LegacyRequirementsHook] attribute: Drupal 11.3 and later will not run it, and will not report the deprecation.
Theme hooks, since Drupal 11.3
This was the last big exception: themes can now implement their hooks in object-oriented code too (change record), in mon_theme/src/Hook/MonThemeHooks.php, exactly as for a module:
namespace Drupal\mon_theme\Hook;
use Drupal\Core\Hook\Attribute\Hook;
class MonThemePreprocessHooks {
#[Hook('preprocess_html')]
public function preprocessHtml(array &$variables): void {
...
}
}Two limitations to be aware of. First, themes can only implement a subset of hooks: hook_theme, preprocess functions, and a finite list of alters (form_alter, form_FORM_ID_alter, css_alter, js_alter, library_info_alter, element_info_alter, page_attachments_alter, theme_registry_alter, theme_suggestions_alter…). Second, the Hook attribute accepts neither order nor module there, and neither #[ReorderHook] nor #[RemoveHook] is supported: the base theme's hooks run before the active theme's, and that's it.
Drupal 10 compatibility
The question comes up less and less, with Drupal 10 reaching end of support in December 2026, but if your module still has to run on it, the principle hasn't changed: you keep the procedural implementation, which simply calls the class.
First, add the class to your mon_module.services.yml file (pointless on Drupal 11, where registration is automatic, but required for Drupal 10):
services:
Drupal\mon_module\Hook\FormHooks:
class: Drupal\mon_module\Hook\FormHooks
autowire: trueThen add the call to the class in mon_module.module:
/**
* Implements hook_form_alter().
*/
#[LegacyHook]
function mon_module_form_alter(&$form, FormStateInterface $form_state, $form_id) {
\Drupal::service(FormHooks::class)->formAlter($form, $form_state, $form_id);
}
/**
* Implements hook_form_node_programme_edit_form_alter().
*/
#[LegacyHook]
function mon_module_form_node_programme_edit_form_alter(&$form, FormStateInterface $form_state, $form_id) {
\Drupal::service(FormHooks::class)->formProgrammeAlter($form, $form_state, $form_id);
}
/**
* Implements hook_form_node_programme_form_alter().
*/
#[LegacyHook]
function mon_module_form_node_programme_form_alter(&$form, FormStateInterface $form_state, $form_id) {
\Drupal::service(FormHooks::class)->formProgrammeAlter($form, $form_state, $form_id);
}The #[LegacyHook] attribute does the job: from Drupal 11.1 onwards, it tells core not to run the procedural function, to avoid a duplicate of the object-oriented implementation. On Drupal 10, which ignores it, only the procedural function runs.
As soon as your module requires at least Drupal 11.1 (core_version_requirement: ^11.1 || ^12), these two adjustments become dead code: you can remove them.
Note: the patch I mentioned in the initial version of this article, so that Drupal 10 knows about the attributes and phpstan validates your code, is no longer relevant. The backport of the attribute declarations (issue #3482464) ultimately only survived in the 10.3 branch, which is now unmaintained: on 10.4 and later, the Hook and LegacyHook classes are simply missing from core. So you need to add a phpstan exclusion for unknown attributes or, more sensibly in 2026, migrate to Drupal 11 first and then convert your hooks.
A small performance tip
There is a parameter to tell Drupal that it doesn't need to scan a module's files looking for hook implementations (mainly the .module files). This is micro-optimization territory, but it can come in handy.
Be careful, both names changed in Drupal 11.2; the examples below are the ones to use today.
If you don't use any procedural hooks (or only for backward compatibility with Drupal < 11.1), you can add the following key to your services.yml file:
parameters:
mon_module.skip_procedural_hook_scan: true(in Drupal 11.1, this parameter was called mon_module.hooks_converted)
If some of your files still contain procedural hooks, put them all at the top of the file and add to your use statements:
use Drupal\Core\Hook\Attribute\ProceduralHookScanStop;Then place the following attribute on the first function after your last procedural hook:
#[ProceduralHookScanStop]
function mon_module_une_fonction_qui_nest_pas_un_hook() {
...
}(in Drupal 11.1, this attribute was called #[StopProceduralHookScan])
That way, Drupal knows it doesn't need to scan the rest of the file for potential hooks.
The change record: https://www.drupal.org/node/3490771
Converting your hooks automatically
For a large module, no need to move everything by hand: drupal-rector ships a HookConvertRector rule that moves each procedural hook into a src/Hook/*Hooks.php class. It is used with a dedicated configuration, in two passes:
# 1. first fix deprecations, with your usual rector.php
vendor/bin/rector process web/modules/custom/mon_module
# 2. then convert the hooks
vendor/bin/rector process web/modules/custom/mon_module \
--config=vendor/palantirnet/drupal-rector/rector-hook-convert.phpTwo passes, because Rector doesn't go back over the files it has just created: without the first one, deprecations would be copied as is into the new classes. Running PHPCS afterwards doesn't hurt either, as the conversion leaves a few leftovers (notably use statements that are no longer needed).
What about Drupal 12?
Drupal 12 is expected the week of December 7, 2026. Procedural hooks are not yet officially deprecated there: the issue meant to handle it is on hold, as core is leaning towards deprecating the .module files themselves instead. They should therefore keep working on Drupal 12, with removal not planned before Drupal 13. No rush, then, but for new code there is no longer much reason to write a procedural hook.
The reference change record, kept up to date: https://www.drupal.org/node/3442349
Comments (0)