Hooks and events
Drupal’s defining ability is that modules can react to and alter what core and other modules do, without patching them. Two mechanisms provide it: hooks, Drupal’s own named interception points, and events, the Symfony dispatcher pattern. This page teaches both as they exist in Drupal 11 today: hooks are methods on classes marked with the Hook attribute, and event subscribers are autoconfigured services.
What and why
Section titled “What and why”A hook is a named moment with a defined signature: “an entity is about to be saved” (hook_entity_presave()), “a form is being built” (hook_form_alter()). Any module may implement any hook, and Drupal calls every implementation at the right moment. Hooks are how a module cleans up its own data when a node is deleted, and how a five-line implementation can change any form on the site.
Events serve the same goal with a different lineage: a dispatcher sends an event object to registered subscribers, who can inspect or modify it. Core uses events chiefly for the request lifecycle (routing, responses, exceptions) and for config operations.
Without these mechanisms, customizing Drupal would mean forking it. With them, core itself is built as modules listening to each other; your module participates in exactly the same system.
Mental model
Section titled “Mental model”One sentence to keep: a hook implementation is a method on an ordinary service class, and the attribute on it is the registration.
Pick the mechanism by the moment. For the request lifecycle, such as routing, responses, or exceptions, subscribe to a kernel event. For config operations, subscribe to a config event. For an entity lifecycle moment, such as presave or delete, implement the matching hook. For altering a form or other module output, implement an alter hook. Hooks and events cover different moments rather than replacing each other.
Hook classes
Section titled “Hook classes”Implementations live in classes under your module’s src/Hook/ directory (the hook collector scans exactly that directory). Each class is registered automatically as an autowired service, with the class name as the service ID, so constructors get dependency injection with no YAML:
<?php
declare(strict_types=1);
namespace Drupal\event_rsvp\Hook;
use Drupal\Core\Hook\Attribute\Hook;use Drupal\event_rsvp\RsvpStorage;use Drupal\node\NodeInterface;
/** * Hook implementations for the Event RSVP module. * * Since Drupal 11.1, hooks live in autowired classes with the #[Hook] * attribute instead of procedural functions in the .module file. */class EventRsvpHooks {
/** * Constructs the hook implementation class. * * @param \Drupal\event_rsvp\RsvpStorage $rsvpStorage * The RSVP storage service. */ public function __construct( protected readonly RsvpStorage $rsvpStorage, ) {}
/** * Implements hook_ENTITY_TYPE_delete() for node entities. */ #[Hook('node_delete')] public function nodeDelete(NodeInterface $node): void { // Don't leave orphaned RSVP records behind when an event is deleted. $this->rsvpStorage->deleteForNode((int) $node->id()); }
}The Hook attribute accepts the hook name without the hook_ prefix, and can be placed three ways:
- On a method, as in the snippet above:
#[Hook('node_delete')]directly over the implementation. - On the class, naming the method:
#[Hook('user_cancel', method: 'userCancel')]. - On a class with an
__invoke()method:#[Hook('user_cancel')]on the class, and__invoke()is taken as the implementation. A tidy fit for a class that implements exactly one hook.
Method names are yours to choose; the attribute, not a naming convention, binds the implementation to the hook. That has a consequence the procedural system never allowed: one module may implement the same hook several times, in different methods or classes, each scoped to one concern. (Four hooks are excepted and allow only one implementation per module: hook_library_info_build(), hook_mail(), hook_help(), and hook_node_update_index().)
Alter hooks work identically; here is a targeted form alter from the examples project:
/** * Implements hook_form_FORM_ID_alter() for the site information form. * * Targeted alter hooks like this one beat a hook_form_alter() with an * if-tree: Drupal only calls you for the form you care about. */#[Hook('form_system_site_information_settings_alter')]public function siteInformationFormAlter(array &$form, FormStateInterface $form_state): void { // Add a description to an existing element. $form['site_information']['site_slogan']['#description'] = $this->t('Keep it short: the slogan appears in browser tabs.');
// Add a custom validator. It runs alongside the form's own ones. $form['#validate'][] = [static::class, 'validateSlogan'];}
/** * Validates that the slogan is not too long. * * @param array $form * The form render array. * @param \Drupal\Core\Form\FormStateInterface $form_state * The form state. */public static function validateSlogan(array &$form, FormStateInterface $form_state): void { if (mb_strlen((string) $form_state->getValue('site_slogan')) > 80) { $form_state->setErrorByName('site_slogan', t('The slogan must be 80 characters or fewer.')); }}Ordering implementations
Section titled “Ordering implementations”When several modules implement one hook, order sometimes matters. Instead of weights or counting on alphabetical accidents, the order parameter takes an Order enum case or a relative rule:
order: Order::Firstororder: Order::Lastorder: new OrderBefore(['other_module'])ornew OrderAfter([...])(OrderBefore,OrderAfter)
Two companion attributes act on other implementations, which no procedural mechanism could do cleanly:
ReorderHookrepositions a specific implementation in another module.RemoveHookremoves one entirely.
Core relies on both. The Workspaces module must see entity saves before Content Moderation does, so it declares, in core/modules/workspaces/src/Hook/EntityOperations.php:
#[Hook('entity_presave', order: Order::First)]#[ReorderHook('entity_presave', class: ContentModerationHooks::class, method: 'entityPresave', order: new OrderBefore(['workspaces']))]public function entityPresave(EntityInterface $entity): void {And the Navigation module takes over Layout Builder’s help text by removing that implementation, in core/modules/navigation/src/Hook/NavigationHooks.php:
#[Hook('help')]#[RemoveHook('help', class: LayoutBuilderHooks::class, method: 'help')]public function help($route_name, RouteMatchInterface $route_match): ?string {The hooks that stay procedural
Section titled “The hooks that stay procedural”A bounded set of hooks cannot move into classes, because they run when the container that would hold hook classes is unavailable or about to change. The Hook attribute's documentation lists them:
- Legacy meta hooks:
hook_hook_info(),hook_module_implements_alter() - Install hooks:
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()
These live in .install (or .post_update.php) files as plain functions, exactly as before; see custom database tables and update paths.
Themes use hook classes too Added in 11.3. A theme puts them in src/Hook/ under the Drupal\THEMENAME\Hook namespace, and Drupal registers them as autowired services exactly as it does for modules. Olivero’s OliveroPagePreprocessHooks implements #[Hook('preprocess_html')] this way. Functions in the .theme file are the older form, and they still work.
One related switch worth knowing: a module with no procedural hooks at all can declare parameters: { mymodule.skip_procedural_hook_scan: true } in its services.yml, sparing Drupal from scanning its files for legacy implementations. Core modules do this.
Supporting older Drupal: LegacyHook
Section titled “Supporting older Drupal: LegacyHook”Contrib modules that support Drupal versions without class-based hooks keep a procedural stub that delegates to the class, marked with LegacyHook so Drupal 11 does not run it twice:
// mymodule.module: kept only for Drupal versions before OO hooks.#[LegacyHook]function mymodule_node_delete(NodeInterface $node): void { \Drupal::service(MyModuleHooks::class)->nodeDelete($node);}On old versions only the function runs; on Drupal 11 only the class method does. If your module requires Drupal 11.1 or later, you do not need any of this. The convert procedural hooks to classes guide covers converting an existing module, stubs included.
Events
Section titled “Events”An event subscriber is a service implementing EventSubscriberInterface: getSubscribedEvents() maps event names to methods, with an optional priority per method (higher runs earlier).
<?php
declare(strict_types=1);
namespace Drupal\docs_snippets\EventSubscriber;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;use Symfony\Component\HttpKernel\Event\ResponseEvent;use Symfony\Component\HttpKernel\KernelEvents;
/** * Adds a custom header to every response. * * Event subscribers react to moments in the request lifecycle (and to * events other modules dispatch). Register the class as a service with * the "event_subscriber" tag in docs_snippets.services.yml. */class ResponseSubscriber implements EventSubscriberInterface {
/** * {@inheritdoc} */ public static function getSubscribedEvents(): array { // Event name => method to call. The optional integer is a priority; // higher numbers run earlier. return [ KernelEvents::RESPONSE => ['onResponse', 0], ]; }
/** * Adds the header before the response is sent. * * @param \Symfony\Component\HttpKernel\Event\ResponseEvent $event * The response event, carrying the request and response objects. */ public function onResponse(ResponseEvent $event): void { $event->getResponse()->headers->set('X-Docs-Snippets', 'hello'); }
}Registration is one service entry; with autoconfigure: true in your services.yml _defaults, the event_subscriber tag is applied automatically because \Drupal\Core\CoreServiceProvider registers EventSubscriberInterface for autoconfiguration. Adding the tag by hand still works and is what you will see in some existing code; it is no longer necessary.
Choosing between the two systems is rarely agonizing, because the moment dictates the mechanism:
- If a hook exists for your moment (anything entity-, form-, field-, user-, or theme-shaped), implement the hook. Hooks are also the interoperability layer other modules expect to alter.
- If the moment is request-lifecycle-shaped (responses, redirects, exceptions, routing) or comes from a subsystem that dispatches events (config save/import, migrations), subscribe to the event. There is no hook for “a response is about to be sent”.
- Your own module can define either. Dispatch an event when you want listeners to receive a typed object; invoke a hook when you want to fit the alteration conventions the rest of Drupal follows.
How core uses it
Section titled “How core uses it”- The Node module is the proof of the class-based idiom.
core/modules/node/src/Hook/contains over ten focused classes:NodeEntityHooks,NodeFormHooks,NodeThemeHooks,NodeTokensHooks, and so on. One module, dozens of hook implementations, grouped by concern instead of dumped into one giantnode.modulefile. When you wonder how to organize hook classes, copy this layout. - Cross-module choreography happens in attributes. The Workspaces/Content Moderation ordering and the Navigation/Layout Builder removal shown above are shipped core code, not hypotheticals.
- Core subscribes to itself. Routing, maintenance mode, response finishing, and exception pages are all event subscribers in
core/lib/Drupal/Core/EventSubscriber/; the request lifecycle page walks through them. - Navigation leans on hook ordering.
#[Hook('page_top', order: Order::Last)]inNavigationHooksmakes itspage_topimplementation run after every other module’s, so it can remove the toolbar other code added and put the new navigation bar in place.
Common misconceptions
Section titled “Common misconceptions”- “Hooks are functions in a .module file.” That is the legacy form, not the current one. In Drupal 11, hooks are attribute-marked methods on autowired classes in
src/Hook/; core’s own modules are implemented that way, and since Drupal 11.3 so are core themes such as Olivero and Claro. Write new implementations as classes; keep.moduleonly for what must stay procedural. - “A module can implement a hook only once.” True for procedural hooks (one function name per module), false for hook classes: multiple implementations of the same hook per module are supported, minus the four single-implementation exceptions listed above.
- “hook_update_N and hook_install can move into hook classes too.” They cannot, by design: update and install hooks run while module code and the container are in transition, so they stay procedural in
.installfiles. The Hook attribute’s own documentation carries the authoritative list. - “I still need hook_module_implements_alter() to reorder implementations.” The ordering attributes (
order:,ReorderHook,RemoveHook) replace it with declarative, per-implementation control, and it acts on whole modules, not individual methods: it runs at container build time and reorders or removes a module’s class-based implementations along with its procedural ones. Reach for it only in legacy code, and mark it with#[LegacyModuleImplementsAlter]so Drupal 11.2 and later skip it. - “Event subscribers must be tagged with event_subscriber manually.” Autoconfiguration applies the tag from the interface. A plain service entry under
_defaults: autoconfigure: trueis a complete registration. - “Hooks are deprecated and events replace them.” Not in Drupal 11. Hooks moved to a class-based implementation precisely because they are staying; events and hooks cover different moments, as described above.
Where to go next
Section titled “Where to go next”- Convert procedural hooks to classes: migrate an existing module’s
.modulefile to hook classes, including BC stubs. - Services and events: write and register event subscribers, with working code.
- Services and dependency injection: why hook classes get autowired constructors.
- The request lifecycle: the kernel events you can subscribe to.
Hooks topic on api.drupal.organdEvents topic.
Was this page helpful?
Drupal is a registered trademark of Dries Buytaert.