Hooks and OOP Hooks in Drupal Modules

This content was generated with the assistance of AI and should be verified against the official Drupal documentation before being relied on in production.

This sub-section’s Drupal bibliography lists the reference material consulted while preparing these pages.

A hook is the oldest extension mechanism in Drupal. Core, or any module, announces that something is about to happen (a form is being built, an entity is being saved, cron is running, a page is about to be rendered) and every module that registered an implementation gets a chance to react or to change the data. Almost every behaviour that the Meridian Museum site needs beyond configuration (hiding fields from non-curators, normalising an accession number, clearing a listing cache when an object changes, queueing a synchronisation on cron) is a hook implementation. For more than fifteen years that meant a function in a .module file. Since Drupal 11.1 hooks are implemented as methods on ordinary classes, found through the #[Hook] PHP attribute, instantiated by the service container and given their dependencies through the constructor. This page explains both worlds, because contributed modules, older books and Stack Overflow answers still show the procedural form, and it says exactly which hooks can never leave the procedural world.

This page was checked against the official documentation, change records and the Drupal 11.x source on 2026-10-30. It is written against Drupal 11.4.x, Drush 13 or 14 and PHP 8.3 or later (see Requirements and hosting). The examples belong to the fictitious Meridian Museum site from the sub-section index: they extend the custom module meridian_collection (see Module anatomy and info.yml) and the theme meridian. Drupal 10 reaches its end of life on 2026-12-09, so new work targets Drupal 11. Drupal 12.0.0 is planned for the week of 2026-12-07, and the version notes below say which hook behaviour changes there.

What a hook is

A hook is a named extension point with a defined set of parameters and, for some hooks, a defined return value. The name always starts with hook_ in documentation (for example hook_form_alter, hook_entity_presave, hook_cron). Core documents every core hook in *.api.php files (such as core/lib/Drupal/Core/Form/form.api.php and core/lib/Drupal/Core/Entity/entity.api.php) that are never loaded at runtime; they exist so that the API reference at api.drupal.org can describe the signature. A module implements a hook by providing code for it, and the hook system calls every implementation in a defined order. There are three flavours:

  • Notification (event-like) hooks such as hook_cron, hook_entity_insert and hook_user_login: implementations do something and usually return nothing.

  • Info and collection hooks such as hook_theme or hook_entity_bundle_info: every implementation returns an array and the results are merged.

  • Alter hooks (hook_form_alter, hook_entity_type_alter, hook_library_info_alter, and thousands more): the caller passes data by reference to each implementation in turn so that modules can modify it before it is used. The name of an alter hook always ends in _alter.

Hooks have a place in the architecture next to the other extension mechanisms. Services and plugins are chosen explicitly (a controller names its service, a block configuration names its plugin ID), while a hook is broadcast to all installed modules and the caller does not know who listens. Event subscribers (see Events and subscribers) are the Symfony equivalent of notification hooks. Hooks remain the mechanism that core uses for entity lifecycle, forms, themes, mail, cron and the alter points, and they are what a site builder reaches for first when a setting cannot express a requirement.

flowchart TD A[Classes in src/Hook with the Hook attribute] --> B[Container build: hook collection] C[Procedural functions in .module and .theme files] --> B B --> D[(Implementation map for every hook)] E[Code calls module_handler: invoke, invokeAll or alter] --> D D --> F[Order by module weight and name, then order arguments] F --> G[Each implementation runs; hook classes come from the container]

Implementing a hook as a class (Drupal 11.1 and later)

Change record 3442349 introduced object-oriented hook implementations in Drupal 11.1. The rules are short:

  • Put the class in the namespace Drupal\<module_name>\Hook, or in a sub-namespace of it, so the file lives in src/Hook/ (for example src/Hook/CollectionFormHooks.php). The class name and the method names are free.

  • Mark each method with #[Hook('name')], where name is the hook name without the hook_ prefix. The attribute class is Drupal\Core\Hook\Attribute\Hook.

  • The method takes the same parameters, and returns the same type, as the procedural implementation of that hook would.

  • Core registers every class found in a module’s Hook namespace as an autowired service whose ID is the class name. There is nothing to add to meridian_collection.services.yml. Constructor arguments are resolved with the usual autowiring rules (see Services and dependency injection): type-hint an interface that has an autowiring alias (core provides them for its common services since 10.1), or use #[Autowire(service: 'cache_tags.invalidator')] from Symfony\Component\DependencyInjection\Attribute\Autowire for anything else.

  • The hook map is built when the container is compiled. After adding, renaming or removing a #[Hook] attribute, rebuild the cache (drush cache:rebuild) or the change is not seen.

A first example: an alter hook with an injected service

The curators of the museum may promote exhibitions to the front page and pin them at the top of lists; ordinary content editors may not. The form alter hook below hides the two checkboxes for everyone without the permission. It implements two hooks at once (the add form and the edit form of the exhibition content type) with one method.

<?php

declare(strict_types=1);

namespace Drupal\meridian_collection\Hook;

use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Hook\Attribute\Hook;
use Drupal\Core\Session\AccountInterface;

/**
 * Form alterations for Meridian Museum content forms.
 */
class CollectionFormHooks {

  public function __construct(
    protected AccountInterface $currentUser,
  ) {}

  /**
   * Hides front page promotion from users who may not curate exhibitions.
   *
   * Implements hook_form_FORM_ID_alter() for the add and edit forms.
   */
  #[Hook('form_node_exhibition_form_alter')]
  #[Hook('form_node_exhibition_edit_form_alter')]
  public function exhibitionFormAlter(array &$form, FormStateInterface $form_state, string $form_id): void {
    $may_curate = $this->currentUser->hasPermission('edit collection objects');
    foreach (['promote', 'sticky'] as $key) {
      if (isset($form[$key])) {
        $form[$key]['#access'] = $may_curate;
      }
    }
  }

}

Three details of this example are worth knowing:

  • AccountInterface is autowired because core aliases it to the current_user service. No static \Drupal::currentUser() call appears in the class, which also makes the class trivial to unit test with a mock account.

  • Setting #access to FALSE is the correct way to hide an element. Removing the key with unset() can leave the value to be filled from defaults, and hiding with CSS is not a security measure at all. The submitted value of an inaccessible element is ignored by the Form API (see Form API).

  • The two attributes are on the same method. The hook name is form_node_exhibition_form_alter for the form ID node_exhibition_form and form_node_exhibition_edit_form_alter for node_exhibition_edit_form. A third, general option is #[Hook('form_node_form_alter')], which uses the base form ID node_form and runs for every node form; inside it, read the bundle from the form object.

Several hooks, several methods, several classes

A module may have as many hook classes as it wants, and one hook may be implemented by several methods of one module. The recommended organisation is by topic, not by hook type: CollectionFormHooks, CollectionEntityHooks, CollectionCronHooks, CollectionThemeHooks. The older practice of one .module file holding a long list of unrelated functions is exactly what the class form replaces. Two limits apply: a handful of hooks that are looked up by module name may be implemented only once per module (hook_library_info_build, hook_mail, hook_help and hook_node_update_index), and conditional definitions are not possible, because the map is built from the attributes at container build time.

The next class handles the entity lifecycle of the collection_object content entity. One method carries three attributes, so the same code runs when an object is created, updated or deleted, and another method normalises the accession number before saving.

<?php

declare(strict_types=1);

namespace Drupal\meridian_collection\Hook;

use Drupal\Core\Cache\CacheTagsInvalidatorInterface;
use Drupal\Core\Entity\ContentEntityInterface;
use Drupal\Core\Entity\EntityInterface;
use Drupal\Core\Hook\Attribute\Hook;
use Symfony\Component\DependencyInjection\Attribute\Autowire;

/**
 * Reacts to the lifecycle of collection objects.
 */
class CollectionEntityHooks {

  public function __construct(
    #[Autowire(service: 'cache_tags.invalidator')]
    protected CacheTagsInvalidatorInterface $cacheTagsInvalidator,
  ) {}

  /**
   * Upper-cases and trims the accession number, e.g. " mm.2024.017 " to MM.2024.017.
   *
   * Implements hook_ENTITY_TYPE_presave() for collection_object.
   */
  #[Hook('collection_object_presave')]
  public function normalizeAccessionNumber(EntityInterface $entity): void {
    if ($entity instanceof ContentEntityInterface && $entity->hasField('accession_number')) {
      $value = (string) $entity->get('accession_number')->value;
      $entity->set('accession_number', strtoupper(trim($value)));
    }
  }

  /**
   * Clears every listing that shows collection objects.
   *
   * One method, three hooks: hook_ENTITY_TYPE_insert(), _update() and _delete().
   */
  #[Hook('collection_object_insert')]
  #[Hook('collection_object_update')]
  #[Hook('collection_object_delete')]
  public function invalidateListings(EntityInterface $entity): void {
    $this->cacheTagsInvalidator->invalidateTags(['meridian_collection:objects']);
  }

}

The hook_ENTITY_TYPE_* pattern (collection_object_presave, node_insert, user_update) is the specific variant of the generic hook_entity_* hooks (entity_presave, entity_insert). The generic one fires for every entity type, the specific one only for the named type; prefer the specific one, because the generic hooks run on every save of every entity on the site and add up. Cache tags and the render pipeline are covered in Render API and cache metadata.

The attribute on the class: __invoke and the method argument

The attribute may also sit on the class. A class with __invoke() is the listener itself, and a class attribute with a method argument names the method to call. This suits a hook with a single implementation:

<?php

declare(strict_types=1);

namespace Drupal\meridian_collection\Hook;

use Drupal\Core\Hook\Attribute\Hook;
use Drupal\Core\Queue\QueueFactory;
use Symfony\Component\DependencyInjection\Attribute\Autowire;

/**
 * Queues one synchronisation job per cron run.
 */
#[Hook('cron')]
class CollectionCronHooks {

  public function __construct(
    #[Autowire(service: 'queue')]
    protected QueueFactory $queueFactory,
  ) {}

  public function __invoke(): void {
    $this->queueFactory->get('meridian_collection_sync')->createItem(['source' => 'collections-api']);
  }

}

The equivalent with a named method is #[Hook('cron', method: 'queueSync')] on the class. The queue worker that consumes the item is described in Queue, Batch and Cron APIs.

The attribute signature

The constructor of Drupal\Core\Hook\Attribute\Hook is ($hook, $method = '', $module = NULL, $order = NULL):

Argument Meaning

hook

The hook name without the hook_ prefix.

method

The method to call. Omit it on a method attribute. On a class attribute it is required unless the class has __invoke().

module

The module the implementation is for. Defaults to the module that owns the class. It lets one module implement a hook on behalf of another, which is rarely needed.

order

Where the implementation runs relative to the others. See the ordering section below (Drupal 11.2 and later).

Procedural implementations still work

The class form is the recommended style for new code on Drupal 11.1 and later, but a function named {module}_{hook} in the .module file is still discovered. A module that needs to run on 10.x as well as on 11.x keeps them. The documentation of the hook system states plainly that the procedural form is a legacy of the pre-attribute API:

<?php

// modules/custom/meridian_collection/meridian_collection.module

use Drupal\Core\Form\FormStateInterface;

/**
 * Implements hook_form_FORM_ID_alter() for node_exhibition_form.
 */
function meridian_collection_form_node_exhibition_form_alter(array &$form, FormStateInterface $form_state, string $form_id): void {
  // Works on every Drupal 8 to 11 release. Prefer a Hook class on 11.1+.
}

Support for procedural hooks is not permanent. Change record 3442349 states that they are expected to be removed in Drupal 13 at the earliest, together with the compatibility shims described next. The 11.5 development line goes further: the deprecation of the .profile file extension (change record 3624829, introduced in 11.5.0) tells developers to move hook implementations and helper functions into classes and services, with autoloading of .profile files ending in Drupal 13, and the same path has been reported for .module and .theme files. In Drupal 12, procedural hooks are collected only from the main .module, .theme and theme-settings.php files: hook functions in include files are silently ignored, and the old hook_hook_info mechanism that loaded them was removed (change record 3613767, 12.0.0). Hooks that were converted and carry #[LegacyHook] may stay in include files. The practical conclusion is that a module that is converted completely, with no .module hooks left, is the one that will need no work for Drupal 13.

Procedural and class implementations in the same module

From Drupal 11.2, when one module has both a procedural and a class implementation of the same hook, the procedural function runs first, unless an order argument says otherwise (change record 3515207). Before 11.2 the relative order was not guaranteed and could vary between installations. Implementing the same hook in both forms is almost always a mistake (it runs the code twice) and is only correct when the function is a deliberate compatibility shim marked with #[LegacyHook], described below.

Keeping Drupal 10.3 and earlier working: #[LegacyHook]

Many contributed modules support several majors at once. Change record 3442349 describes a shim that works on all of them:

  1. Write the hook class as shown above.

  2. On sites older than 10.1, where core does not alias its services for autowiring, register the class in meridian_collection.services.yml with autowire: true. From 10.1, core’s aliases make this unnecessary for the common services; on 11.1 and later the registration is automatic and the file entry is harmless.

  3. Keep a procedural function in the .module file, mark it #[LegacyHook], and have it forward the call to the service.

<?php

// modules/custom/meridian_collection/meridian_collection.module

use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Hook\Attribute\LegacyHook;
use Drupal\meridian_collection\Hook\CollectionFormHooks;

/**
 * Implements hook_form_FORM_ID_alter() for node_exhibition_form.
 *
 * Kept for Drupal 10.1 to 11.0. Drupal 11.1 and later skip this function and
 * call CollectionFormHooks::exhibitionFormAlter() directly.
 */
#[LegacyHook]
function meridian_collection_form_node_exhibition_form_alter(array &$form, FormStateInterface $form_state, string $form_id): void {
  \Drupal::service(CollectionFormHooks::class)->exhibitionFormAlter($form, $form_state, $form_id);
}
# modules/custom/meridian_collection/meridian_collection.services.yml
# Needed only for Drupal 10.1 to 11.0. Drupal 11.1+ registers Hook classes itself.
services:
  Drupal\meridian_collection\Hook\CollectionFormHooks:
    class: Drupal\meridian_collection\Hook\CollectionFormHooks
    autowire: true
# modules/custom/meridian_collection/meridian_collection.info.yml
name: Meridian Collection
type: module
description: 'Collection objects, listings and synchronisation for the Meridian Museum.'
package: Meridian Museum
core_version_requirement: ^10.3 || ^11

The behaviour by version:

  • 10.1 to 11.0: the procedural shim runs and calls the class. The attribute is ignored, because PHP ignores attributes it cannot resolve.

  • 11.1 and later: the hook system skips every function marked #[LegacyHook] and calls the class method directly. That is the whole purpose of the attribute: without it the same logic would run twice.

  • The Hook and LegacyHook classes do not exist in 10.x releases before 10.3, so a use statement for them can make static analysis (PHPStan) on an older core complain. Comments on the change record advise a local @phpstan-ignore-next-line directly above the attribute, with no blank line in between. This is another reason for a minimum of 10.3 in the example above.

  • A static \Drupal::service() call inside the shim is acceptable, because a procedural function cannot receive constructor arguments. It is the only place where such a call should remain.

A module that requires Drupal 11.1 or later (core_version_requirement: ^11.1, or ^11.2 when it needs the ordering attributes) needs no shim, no services.yml entry and no .module hook functions at all. The Meridian module in this sub-section targets Drupal 11.4 and therefore uses plain classes. Drupal 12 does not need the shims either; they exist for the transition and are expected to disappear together with procedural hooks.

Hooks that must stay procedural

Not every hook can be moved into a class. The change record keeps a list that is updated as follow-up work lands, so treat the following as the state at 11.4 and check the change record before relying on it.

Hook Reason and status

hook_install, hook_uninstall, hook_schema

They run when the module is installed or removed, or when the schema is read before the module’s services exist. They stay in meridian_collection.install.

hook_update_N, hook_post_update_NAME, hook_update_last_removed, hook_update_dependencies, hook_removed_post_updates

Update API hooks are discovered by function name in .install and .post_update.php files. See Update API and post-updates.

hook_install_tasks, hook_install_tasks_alter

Install profile hooks. In the change record for the .profile deprecation, no class replacement exists up to 12.1.0.

hook_requirements, hook_requirements_alter

Replaced rather than converted. hook_runtime_requirements (11.2) and hook_update_requirements have OOP forms, and the legacy hooks are deprecated in 11.3 with removal in 13.0.0. Install-time requirement checks follow their own replacement path. See the section on requirements below.

hook_hook_info

A legacy meta hook that told core to load hooks from other files. Removed in Drupal 12.

hook_module_implements_alter

Replaced by the order argument and the #[ReorderHook] and #[RemoveHook] attributes (11.2). A procedural implementation must carry #[LegacyModuleImplementsAlter] to keep working on older cores.

hook_preprocess_HOOK and template_preprocess_HOOK (themes and old core code)

The preprocess hooks as class methods arrived in 11.2 for modules and in 11.3 for themes. The magic-name template_preprocess_HOOK functions are replaced by an initial preprocess callback in the hook_theme() definition (11.2).

Two further restrictions come straight from the change record: procedural hooks in src/ are not supported (a function named like a hook in a class file is never collected), and conditionally defined hooks are not supported. A hook function wrapped in if (function_exists(...)) or defined by an include in the middle of a .module file will not be found by the class-based collector. Any code that subclassed ModuleHandler is broken by the rewrite and its constructor; the replacement is a service decorator. ModuleHandler::getHookInfo() and ModuleHandler::writeCache() are deprecated with no replacement and are removed in Drupal 12.

flowchart TD S[A hook to implement] --> Q1{Install, uninstall, schema, update or install task hook?} Q1 -- yes --> P[Procedural function in the .install file or the profile] Q1 -- no --> Q2{Implemented by a theme?} Q2 -- yes --> Q3{Theme supports Drupal 11.3 or later only?} Q3 -- yes --> C[Hook class in the theme src/Hook] Q3 -- no --> L[Procedural function in the .theme file, LegacyHook shim] Q2 -- no --> Q4{Module needs Drupal older than 11.1?} Q4 -- no --> C2[Hook class in the module src/Hook] Q4 -- yes --> L2[Hook class plus a LegacyHook shim in the .module file]

The requirements hooks

The status report (see Logging, status report and troubleshooting) shows the output of the runtime requirements hook. hook_requirements($phase) was one function that had to test the phase and return early; since 11.2 the runtime phase has its own hook, hook_runtime_requirements (change record 3490851), with hook_runtime_requirements_alter running after all checks, and the update phase has hook_update_requirements. Both have class forms, and severities are the Drupal\Core\Extension\Requirement\RequirementSeverity enum (Info, OK, Warning, Error); passing the old integer constants is deprecated in 11.2 and is not accepted in Drupal 12. Legacy hook_requirements() implementations that are not marked #[LegacyRequirementsHook] trigger a deprecation in 11.3 and are removed in 13.0.0. A frequent mistake during migration is to mark the legacy function with that attribute and forget to add the runtime hook: on 11.3 and later the old function no longer runs, and the status report entry silently disappears.

<?php

declare(strict_types=1);

namespace Drupal\meridian_collection\Hook;

use Drupal\Core\Extension\Requirement\RequirementSeverity;
use Drupal\Core\Hook\Attribute\Hook;
use Drupal\Core\Site\Settings;
use Drupal\Core\StringTranslation\StringTranslationTrait;

/**
 * Status report entries for the collections-management API connection.
 */
class CollectionRequirementsHooks {

  use StringTranslationTrait;

  /**
   * Implements hook_runtime_requirements().
   */
  #[Hook('runtime_requirements')]
  public function runtime(): array {
    $configured = Settings::get('meridian_collection_api_key') !== NULL;
    return [
      'meridian_collection_api' => [
        'title' => $this->t('Collections API credentials'),
        'value' => $configured ? $this->t('Configured') : $this->t('Missing'),
        'description' => $configured ? '' : $this->t('Set $settings[\'meridian_collection_api_key\'] in settings.php from an environment variable.'),
        'severity' => $configured ? RequirementSeverity::OK : RequirementSeverity::Warning,
      ],
    ];
  }

}

The API key itself is never stored in the code or in exported configuration; settings.php reads it from an environment variable (see settings.php and environments).

Install and update hooks stay in .install

The files of an installed module are still where the install hooks live. The example is short because the full Update API is the subject of a separate page.

<?php

// modules/custom/meridian_collection/meridian_collection.install

/**
 * Implements hook_install().
 */
function meridian_collection_install(bool $is_syncing): void {
  // Do not touch configuration while a configuration import is running.
  if ($is_syncing) {
    return;
  }
  \Drupal::state()->set('meridian_collection.last_sync', 0);
}

/**
 * Add the "on_display" flag default to existing collection objects.
 */
function meridian_collection_update_10001(): void {
  // Batch of updates is described on the Update API page.
}

Because the .install file runs before, or without, a fully built container, \Drupal:: static calls are the normal style here. Use them sparingly and move the real work into a service that the update function fetches.

Ordering, reordering and removal (Drupal 11.2 and later)

Implementations of one hook run in module order: by module weight (stored in the core.extension configuration, normally 0), then alphabetically by machine name. Before 11.2 the only way to change that was hook_module_implements_alter, which received the whole list of implementors and could reorder or remove them. It ran too early to use services, depended on array manipulation and could not be expressed as a declaration. Drupal 11.2 replaced it with a declarative order argument on #[Hook] and with two attributes for changing another module’s implementation: #[ReorderHook] and #[RemoveHook] (change records 3493962, 3497308 and 3496788). All live in the Drupal\Core\Hook\Attribute namespace; the ordering objects Order, OrderBefore and OrderAfter live in Drupal\Core\Hook\Order.

The order argument

<?php

declare(strict_types=1);

namespace Drupal\meridian_collection\Hook;

use Drupal\Core\Entity\EntityInterface;
use Drupal\Core\Hook\Attribute\Hook;
use Drupal\Core\Hook\Order\Order;
use Drupal\Core\Hook\Order\OrderAfter;
use Drupal\Core\Hook\Order\OrderBefore;

class CollectionOrderedHooks {

  /**
   * Runs as late as possible, so that other modules have already set fields.
   */
  #[Hook('entity_presave', order: Order::Last)]
  public function lastPresave(EntityInterface $entity): void {
  }

  /**
   * Runs after Content Moderation has computed the moderation state.
   */
  #[Hook('entity_presave', order: new OrderAfter(modules: ['content_moderation']))]
  public function afterModeration(EntityInterface $entity): void {
  }

  /**
   * Runs before another class method, wherever the class lives.
   */
  #[Hook('entity_presave', order: new OrderBefore(classesAndMethods: [
    [CollectionEntityHooks::class, 'normalizeAccessionNumber'],
  ]))]
  public function beforeNormalize(EntityInterface $entity): void {
  }

}

How the arguments combine:

  • Order::First and Order::Last are simple positions; OrderBefore and OrderAfter take a modules array of machine names or a classesAndMethods array of [Class::class, 'method'] pairs.

  • The default sequence is module order. Ordering operations are applied one after another in module order, each to the sequence produced by the previous one. If several modules ask for Order::First, the one that comes last in module order ends up first (with modules A, B and C, where B and C ask for first, the result is C, B, A). The result is deterministic but not intuitive, so avoid fighting over first and last unless you must.

  • Reordering and removal operations run after all ordering operations, also in module order.

  • Order values are fixed declarations. Ordering cannot be dynamic, which is a deliberate limitation compared with the old alter hook.

  • Procedural and class implementations of one module are sorted procedural first (11.2) unless attributes say otherwise.

sequenceDiagram participant Core as Hook system participant A as Module A (weight 0) participant B as Module B (weight 0) participant M as meridian_collection Core->>Core: Sort by weight, then name: A, B, meridian_collection Core->>Core: Apply order arguments: Order::First for meridian_collection Core->>Core: Apply ReorderHook and RemoveHook operations Core->>M: Run implementation Core->>A: Run implementation Core->>B: Run implementation

Changing someone else’s implementation

To move, or to drop, an implementation that another module provides, declare an attribute on any class or method in your own Hook namespace. The effect does not depend on where the attribute is placed, though the recommendation is to put it on the implementation that needs the change. Both attributes identify the target by hook name, class and method; use __invoke as the method when the target class is itself the listener.

<?php

declare(strict_types=1);

namespace Drupal\meridian_collection\Hook;

use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Hook\Attribute\Hook;
use Drupal\Core\Hook\Attribute\RemoveHook;
use Drupal\Core\Hook\Attribute\ReorderHook;
use Drupal\Core\Hook\Order\OrderBefore;
use Drupal\content_moderation\Hook\ContentModerationHooks;
use Drupal\acme_seo\Hook\SeoFormHooks;

class CollectionOverrideHooks {

  /**
   * Makes Workspaces-style ordering explicit: our presave runs before Content Moderation's.
   */
  #[ReorderHook(
    hook: 'entity_presave',
    class: ContentModerationHooks::class,
    method: 'entityPresave',
    order: new OrderBefore(modules: ['meridian_collection']),
  )]
  public function reorderModeration(): void {
  }

  /**
   * Replaces a contributed module's form alteration with our own.
   *
   * A replacement is a RemoveHook plus a Hook of the same name on the same class.
   */
  #[RemoveHook(hook: 'form_node_exhibition_form_alter', class: SeoFormHooks::class, method: 'exhibitionFormAlter')]
  #[Hook('form_node_exhibition_form_alter')]
  public function replaceSeoFormAlter(array &$form, FormStateInterface $form_state, string $form_id): void {
    // Our own, curator-friendly version of the SEO fields.
  }

}

The first attribute is adapted from the change record, in which the Workspaces module needs Content Moderation’s entity_presave implementation to run first. Drupal\acme_seo in the second is a fictitious contributed module. The documented removal attribute is RemoveHook($hook, $class, $method); a replacement is the combination of removing the other module’s listener and registering your own, since there is no separate "replace" attribute in core. (Contributed modules such as Hux offer their own replacement attributes for override use cases; they are not part of core.)

Replacing hook_module_implements_alter

A module that ordered or removed implementations with the old alter hook migrates in three steps. First reproduce the effect with order, #[ReorderHook] or #[RemoveHook]. Second, keep the old function only while older cores must be supported, and mark it #[LegacyModuleImplementsAlter] (from Drupal\Core\Hook\Attribute): since 11.2 a procedural hook_module_implements_alter without this attribute no longer works. Third, when the minimum core version of the module reaches 11.2, delete the function and the attribute. Because the alter hook ran at container build time, it never supported injected services, and the replacements do not either.

<?php

// modules/custom/meridian_collection/meridian_collection.module

use Drupal\Core\Hook\Attribute\LegacyModuleImplementsAlter;

/**
 * Implements hook_module_implements_alter().
 *
 * Needed only while Drupal 10.3 to 11.1 are supported; the same effect is declared
 * with Order::First on the Hook attribute for Drupal 11.2 and later.
 */
#[LegacyModuleImplementsAlter]
function meridian_collection_module_implements_alter(array &$implementations, string $hook): void {
  if ($hook === 'entity_view_alter' && isset($implementations['meridian_collection'])) {
    $mine = $implementations['meridian_collection'];
    unset($implementations['meridian_collection']);
    $implementations = ['meridian_collection' => $mine] + $implementations;
  }
}

Alter hooks in depth

An alter hook lets modules edit a value before the caller uses it. The caller passes the value by reference; each implementation takes it as &$data, optionally followed by context parameters. The name is hook_TYPE_alter, and core’s most common ones are:

Hook What it changes

hook_form_alter, hook_form_FORM_ID_alter, hook_form_BASE_FORM_ID_alter

A form render array, before it is built. Runs in this order: the generic one, the base form ID, the specific form ID.

hook_entity_type_alter, hook_entity_base_field_info_alter, hook_entity_bundle_field_info_alter

Entity type definitions and fields. Runs very early.

hook_library_info_alter, hook_css_alter, hook_js_alter

Asset libraries and aggregated assets (see Libraries and assets).

hook_menu_links_discovered_alter, hook_link_alter, hook_block_view_alter

Menu links, links and blocks.

hook_views_data_alter, hook_views_query_alter

Views data and queries (see Views).

hook_theme_registry_alter, hook_theme_suggestions_alter

The theme registry and template suggestions (see Template suggestions and preprocess).

hook_plugin_info_alter-style (hook_block_alter, hook_field_formatter_info_alter)

Plugin definitions, as an alternative to a plugin deriver (see Plugin API and attributes).

A particular pitfall is that alter hooks that run while core is building its own definitions cannot depend on services that need those definitions. A hook class that injects EntityTypeManagerInterface into a class that also implements hook_entity_type_alter creates a circular dependency: building the entity type manager calls the hook, which needs the entity type manager. The same applies to hook_module_implements_alter. Keep such hook classes dependency-free, or put the alter in its own small class without the dependent services.

Altering with an unusual order of arguments

The context parameters of an alter hook depend on the call. hook_form_alter receives (array &$form, FormStateInterface $form_state, string $form_id); hook_entity_view_alter receives (array &$build, EntityInterface $entity, EntityViewDisplayInterface $display). Always read the *.api.php declaration for the exact order, and note that alter hooks for a nested type such as hook_form_FORM_ID_alter are separate hooks: implementing hook_form_alter does not make hook_form_node_exhibition_form_alter fire a second time.

Defining and invoking your own hook

A module can offer its own extension points so that other modules (for example a future meridian_events module) can change its behaviour. The name must start with the machine name of the defining module to avoid clashes. The module documents the hook in meridian_collection.api.php, a file that is never loaded but is read by documentation tools, and calls it through the module handler service, injected, not fetched statically.

<?php

// modules/custom/meridian_collection/meridian_collection.api.php

/**
 * @file
 * Hooks provided by the Meridian Collection module.
 */

/**
 * Alters the collection objects shown by the "On display now" block.
 *
 * @param int[] $object_ids
 *   The IDs of the objects about to be rendered, in display order.
 * @param array $context
 *   An associative array with the keys 'gallery' (a term ID or NULL) and 'limit'.
 */
function hook_meridian_collection_on_display_alter(array &$object_ids, array $context): void {
  // Remove objects that are on loan during a temporary exhibition.
}
<?php

declare(strict_types=1);

namespace Drupal\meridian_collection;

use Drupal\Core\Extension\ModuleHandlerInterface;

/**
 * Picks the objects shown by the "On display now" block.
 */
class OnDisplaySelector {

  public function __construct(
    protected ModuleHandlerInterface $moduleHandler,
  ) {}

  /**
   * @param int[] $candidate_ids
   *   The IDs of objects whose on_display flag is set.
   *
   * @return int[]
   *   The IDs after every module had a chance to alter them.
   */
  public function select(array $candidate_ids, ?int $gallery, int $limit = 6): array {
    $context = ['gallery' => $gallery, 'limit' => $limit];
    // Calls every hook_meridian_collection_on_display_alter() implementation.
    $this->moduleHandler->alter('meridian_collection_on_display', $candidate_ids, $context);
    return array_slice($candidate_ids, 0, $limit);
  }

}
<?php

declare(strict_types=1);

namespace Drupal\meridian_events\Hook;

use Drupal\Core\Hook\Attribute\Hook;

/**
 * Implements a hook that meridian_collection defines.
 */
class EventsCollectionHooks {

  #[Hook('meridian_collection_on_display_alter')]
  public function onDisplayAlter(array &$object_ids, array $context): void {
    // Put objects tied to a running event first.
    $object_ids = array_values(array_unique($object_ids));
  }

}

The three invocation methods of Drupal\Core\Extension\ModuleHandlerInterface map to the three flavours of hook:

Method Use

invoke($module, $hook, $args)

Call one module’s implementation and return its value (for a hook that has a single owner).

invokeAll($hook, $args)

Call every implementation and merge the returned arrays. For info and collection hooks.

alter($type, &$data, &$context1, &$context2)

Call every hook_TYPE_alter implementation with the data by reference. $type may also be an array of types, to call several alter hooks in order, such as a generic one and a specific one.

invokeAllWith($hook, $callback)

Call a callback with each implementing module and the callable, for code that needs to control the call itself (for example to catch exceptions per implementation).

A hook that is defined by a module and implemented as a class works exactly like a core hook: #[Hook('meridian_collection_on_display_alter')] is found for any installed module, in any order determined by module weight and the order argument. Static calls such as \Drupal::moduleHandler()->alter() do the same thing but belong only to procedural code and tests; in a class, inject module_handler (aliased to ModuleHandlerInterface).

Document the contract of the hook carefully: whether the implementations are expected to add, to filter or to reorder, what the context array contains, whether the order matters and which cache metadata the caller will add. A hook that returns render arrays or entity lists should state what the caller does with the cacheability of the result (see Render API and cache metadata).

Hooks in themes

Themes can implement hooks too, and the class form arrived later than for modules: preprocess hooks as class methods in 11.2, and a general class implementation in themes in 11.3 (change record 3551652). The theme meridian places the classes in src/Hook/ and uses the namespace Drupal\meridian\Hook:

<?php

declare(strict_types=1);

namespace Drupal\meridian\Hook;

use Drupal\Core\Hook\Attribute\Hook;

/**
 * Preprocess functions of the Meridian theme.
 */
class MeridianThemeHooks {

  /**
   * Implements hook_preprocess_node().
   */
  #[Hook('preprocess_node')]
  public function preprocessNode(array &$variables): void {
    $node = $variables['node'];
    if ($node->bundle() === 'exhibition') {
      $variables['attributes']['class'][] = 'exhibition--featured';
    }
  }

}

The restrictions for themes in 11.3 are:

  • the order and module arguments of #[Hook] are not supported in themes, and neither are #[ReorderHook] and #[RemoveHook]. The order within a theme cannot be changed;

  • hooks in a theme that is not the active theme do not run, and a theme cannot implement a hook on behalf of another theme or a module on behalf of a theme;

  • only a subset of normal and alter hooks is available to themes;

  • a theme that must support Drupal older than 11.2 keeps the procedural function in its .theme file, marked #[LegacyHook];

  • files pulled in with include from the .theme file are no longer scanned for hooks unless they are renamed to .inc; in Drupal 12 no include file is scanned at all.

The template_preprocess_HOOK functions used in older code are replaced by the initial preprocess key of the hook_theme() definition (change record 3504125, 11.2), which accepts a callable such as static::class . ':preprocessBlock' resolved through the callable resolver, with injected dependencies. See Template suggestions and preprocess for the template side and Theme settings and configurable themes for the theme-settings.php file, whose hook is moved into a class on 11.3 and later.

Testing hook classes

A hook class is a plain PHP class, so it is easy to test at two levels.

<?php

declare(strict_types=1);

namespace Drupal\Tests\meridian_collection\Unit;

use Drupal\Core\Session\AccountInterface;
use Drupal\meridian_collection\Hook\CollectionFormHooks;
use Drupal\Core\Form\FormState;
use PHPUnit\Framework\Attributes\Group;
use PHPUnit\Framework\TestCase;

#[Group('meridian_collection')]
class CollectionFormHooksTest extends TestCase {

  public function testPromotionIsHiddenWithoutPermission(): void {
    $account = $this->createMock(AccountInterface::class);
    $account->method('hasPermission')->with('edit collection objects')->willReturn(FALSE);
    $form = ['promote' => ['#type' => 'checkbox'], 'sticky' => ['#type' => 'checkbox']];

    (new CollectionFormHooks($account))->exhibitionFormAlter($form, new FormState(), 'node_exhibition_form');

    $this->assertFalse($form['promote']['#access']);
    $this->assertFalse($form['sticky']['#access']);
  }

}
<?php

declare(strict_types=1);

namespace Drupal\Tests\meridian_collection\Kernel;

use Drupal\KernelTests\KernelTestBase;
use PHPUnit\Framework\Attributes\Group;

#[Group('meridian_collection')]
class CollectionHooksRegistrationTest extends KernelTestBase {

  protected static $modules = ['system', 'user', 'meridian_collection'];

  public function testFormAlterIsRegistered(): void {
    $modules = [];
    $this->container->get('module_handler')->invokeAllWith('form_node_exhibition_form_alter', function (callable $hook, string $module) use (&$modules): void {
      $modules[] = $module;
    });
    $this->assertContains('meridian_collection', $modules);
  }

}

The unit test builds the object by hand and needs no Drupal bootstrap. The kernel test asks the module handler which modules implement the hook, which catches a wrong attribute name, a wrong namespace or a forgotten cache rebuild, failures that otherwise look like "my hook does nothing". PHPUnit attributes (#[Group]) replace the annotations from 11.2 on, and PHPUnit 10 support was dropped in 11.3. See Automated testing for test types and how to run them.

Debugging: who implements this hook?

When a form or an entity behaves in an unexpected way, find out which modules implement the hook and in which order:

# Rebuild so the hook map is current, then list the implementers of a hook in run order.
vendor/bin/drush cache:rebuild
vendor/bin/drush php:eval "\Drupal::moduleHandler()->invokeAllWith('form_alter', function (callable \$hook, string \$module) { print \$module . PHP_EOL; });"

Source: Drush: php:eval

A debugger breakpoint in ModuleHandler::alter() shows the list of listeners, and the devel module can print hook implementers in the user interface of a development site only. Install such tools with composer require --dev, never on production.

Converting a module with Drupal Rector

Converting a module of any size by hand is tedious but mechanical. Drupal Rector (palantirnet/drupal-rector) has a HookConvertRector rule that turns procedural hook functions with correct Implements hook_foo() doc comments into #[Hook] methods in classes under src/Hook/. It writes new files directly to disk, so run it on its own, as a second pass after the deprecation rules, and never in the same configuration as the deprecation sets (otherwise deprecated calls inside the bodies are copied unchanged into the new classes).

composer require --dev palantirnet/drupal-rector

# First pass: deprecations, preview then apply.
vendor/bin/rector process web/modules/custom/meridian_collection --dry-run
vendor/bin/rector process web/modules/custom/meridian_collection

# Second pass: hook conversion, with its dedicated configuration.
vendor/bin/rector process web/modules/custom/meridian_collection \
  --config=vendor/palantirnet/drupal-rector/rector-hook-convert.php

# Tidy the generated files and check the result.
vendor/bin/phpcbf --standard=Drupal,DrupalPractice web/modules/custom/meridian_collection
vendor/bin/drush cache:rebuild

The conversion needs review. Reports of the first versions of the rule mention spacing problems around attributes, fully expanded attribute names, duplicated use statements and no injection for \Drupal:: calls in the bodies, which stay static calls until you move them to constructor arguments. A good checklist after running it:

  1. Run the module’s tests and the status report. Hooks marked as converted but missing from the map show up as features that stopped working.

  2. Replace static \Drupal:: calls inside the generated classes with constructor injection, so that the class can be tested (see Services and dependency injection).

  3. Group the generated methods by topic, splitting the single large class the tool tends to produce.

  4. Decide on the minimum core version: with ^11.1 or later drop the #[LegacyHook] shims the rule leaves behind and the services.yml entries.

  5. Add the conversion to code quality tooling (see Coding standards and static analysis).

Security considerations

  • Hooks run with the privileges of the current request. A form alter that unsets a restriction or a hook_entity_access implementation that returns AccessResult::allowed() too liberally affects the whole site. Use isAllowedIf() and isNeutral() rather than allowed() when you only mean to add a reason, and attach cache contexts (user.permissions) to the result (see Access control and permissions in code).

  • Hide with #access, validate on the server. A hidden field is not protection by itself: a hand-built POST can still send the value, which the Form API ignores only when #access is FALSE for the element. Never rely on a client-side state.

  • Do not leak data in alter hooks. A hook_entity_view_alter that adds a private field to the render array exposes it to everyone who may view the entity; check the viewer’s access to the field first.

  • Escape output you build. Strings added in hook_preprocess_* or hook_mail and later printed in Twig are autoescaped; strings forced into markup with Markup::create() or |raw from user input are an XSS risk.

  • Never assume the user is anonymous or trusted in cron. hook_cron runs without a session; entities created or deleted there bypass the permission checks of the user interface unless you perform them.

  • Do not put secrets in hook code. API keys, hash_salt and tokens come from environment variables via settings.php, never from the module code or exported configuration.

  • Mind performance as security. hook_entity_presave, hook_form_alter and hook_preprocess run on frequently used paths; an unbounded query in one of them is a denial-of-service lever for anyone who can trigger that path.

Common errors and pitfalls

Symptom Cause and fix

The hook never runs

The container was not rebuilt after adding the attribute (drush cache:rebuild); the class is outside the Drupal\meridian_collection\Hook namespace; the file is not in src/Hook/; or the module is installed on Drupal older than 11.1.

The hook runs twice

A procedural function and a class method both implement it without #[LegacyHook] on the function. Remove one of them.

The attribute value is hook_form_alter

The hook_ prefix is never part of the attribute: write #[Hook('form_alter')].

ArgumentCountError or a type error in the method

The method signature differs from the hook definition. Compare with the *.api.php documentation; alter hooks pass by reference, so the parameter needs &.

A function in src/ named like a hook is not called

Procedural hooks are collected only from the module’s main extension files, never from classes or arbitrary files.

A hook defined inside an if block is ignored

Conditionally defined hooks are unsupported. Define the hook unconditionally and test the condition inside the body.

Circular reference error when the cache rebuilds

The hook class injects a service that depends on what the hook is building (for example the entity type manager in hook_entity_type_alter). Split the class and leave the early hook dependency-free.

hook_install as a class method does nothing

Install, update and schema hooks stay procedural in .install.

Status report entry disappeared after converting hook_requirements

The legacy function was marked #[LegacyRequirementsHook] without adding hook_runtime_requirements.

Order seems random between two modules

Add an explicit order argument, or #[ReorderHook]; do not rely on module weight alone.

A theme order argument has no effect

Themes do not support order, module, #[ReorderHook] or #[RemoveHook] in 11.3.

PHPStan reports Hook class not found on 10.x

Hook and LegacyHook do not exist before 10.3; raise the minimum or add the targeted ignore comment.

Version notes

Version Change

10.1

Core aliases its common services for autowiring, which the hook shim for older cores relies on.

10.3

First 10.x release with the Hook and LegacyHook classes, usable for a shim.

11.1

Object-oriented hook implementations: #[Hook] on autowired classes in src/Hook (change record 3442349).

11.2

order argument, #[ReorderHook], #[RemoveHook] and #[LegacyModuleImplementsAlter] replace hook_module_implements_alter; procedural implementations run before class ones from the same module; preprocess hooks and initial preprocess callbacks; hook_runtime_requirements; PHPUnit attributes.

11.3

Themes support OOP hooks (a subset, without ordering); legacy hook_requirements deprecated; PHPUnit 10 dropped.

11.5 (in development)

Deprecation of the .profile file extension, with the same direction reported for .module and .theme files; autoloading of those files ends in Drupal 13.

12.0

hook_hook_info removed; procedural hooks collected only from .module, .theme and theme-settings.php; ModuleHandler::getHookInfo() and writeCache() removed; integer requirement severities no longer accepted. See Preparing for Drupal 12.

13

Earliest removal of procedural hook support and of the compatibility shims.

Where older books and tutorials are outdated

Older books such as Drupal 10 Development Cookbook (2023) show hooks only as functions in the .module file, and many examples call the container statically with \Drupal:: inside module code. Both still run, but neither is the recommended style for Drupal 11.1 and later: new implementations are classes in src/Hook with #[Hook], services arrive through the constructor, and the replacement of hook_module_implements_alter by ordering attributes (11.2) did not exist when those books were written. Modernizing Drupal 10 Theme Development (2023) similarly shows .theme preprocess functions with static calls; since 11.2 preprocess can be a class method and since 11.3 a theme can implement hooks as classes. The hook_requirements() examples of that period have been replaced by hook_runtime_requirements and hook_update_requirements. For a wider list, see Where older books and tutorials are outdated and Upgrading Drupal 10 to 11.