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_insertandhook_user_login: implementations do something and usually return nothing. -
Info and collection hooks such as
hook_themeorhook_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.
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 insrc/Hook/(for examplesrc/Hook/CollectionFormHooks.php). The class name and the method names are free. -
Mark each method with
#[Hook('name')], wherenameis the hook name without thehook_prefix. The attribute class isDrupal\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
Hooknamespace as an autowired service whose ID is the class name. There is nothing to add tomeridian_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')]fromSymfony\Component\DependencyInjection\Attribute\Autowirefor 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:
-
AccountInterfaceis autowired because core aliases it to thecurrent_userservice. No static\Drupal::currentUser()call appears in the class, which also makes the class trivial to unit test with a mock account. -
Setting
#accesstoFALSEis the correct way to hide an element. Removing the key withunset()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_alterfor the form IDnode_exhibition_formandform_node_exhibition_edit_form_alterfornode_exhibition_edit_form. A third, general option is#[Hook('form_node_form_alter')], which uses the base form IDnode_formand 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 |
|---|---|
|
The hook name without the |
|
The method to call. Omit it on a method attribute. On a class attribute it is required unless the class has |
|
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. |
|
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:
-
Write the hook class as shown above.
-
On sites older than 10.1, where core does not alias its services for autowiring, register the class in
meridian_collection.services.ymlwithautowire: 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. -
Keep a procedural function in the
.modulefile, 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
HookandLegacyHookclasses do not exist in 10.x releases before 10.3, so ausestatement for them can make static analysis (PHPStan) on an older core complain. Comments on the change record advise a local@phpstan-ignore-next-linedirectly 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 |
|---|---|
|
They run when the module is installed or removed, or when the schema is read before the module’s services exist. They
stay in |
|
Update API hooks are discovered by function name in |
|
Install profile hooks. In the change record for the |
|
Replaced rather than converted. |
|
A legacy meta hook that told core to load hooks from other files. Removed in Drupal 12. |
|
Replaced by the |
|
The preprocess hooks as class methods arrived in 11.2 for modules and in 11.3 for themes. The magic-name
|
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.
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::FirstandOrder::Lastare simple positions;OrderBeforeandOrderAftertake amodulesarray of machine names or aclassesAndMethodsarray 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.
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 |
|---|---|
|
A form render array, before it is built. Runs in this order: the generic one, the base form ID, the specific form ID. |
|
Entity type definitions and fields. Runs very early. |
|
Asset libraries and aggregated assets (see Libraries and assets). |
|
Menu links, links and blocks. |
|
Views data and queries (see Views). |
|
The theme registry and template suggestions (see Template suggestions and preprocess). |
|
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 |
|---|---|
|
Call one module’s implementation and return its value (for a hook that has a single owner). |
|
Call every implementation and merge the returned arrays. For info and collection hooks. |
|
Call every |
|
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
orderandmodulearguments 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
.themefile, marked#[LegacyHook]; -
files pulled in with
includefrom the.themefile 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);
}
}
Source: PHPUnit in Drupal
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
Source: Drupal Rector README
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:
-
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.
-
Replace static
\Drupal::calls inside the generated classes with constructor injection, so that the class can be tested (see Services and dependency injection). -
Group the generated methods by topic, splitting the single large class the tool tends to produce.
-
Decide on the minimum core version: with
^11.1or later drop the#[LegacyHook]shims the rule leaves behind and theservices.ymlentries. -
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_accessimplementation that returnsAccessResult::allowed()too liberally affects the whole site. UseisAllowedIf()andisNeutral()rather thanallowed()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#accessisFALSEfor the element. Never rely on a client-side state. -
Do not leak data in alter hooks. A
hook_entity_view_alterthat 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_*orhook_mailand later printed in Twig are autoescaped; strings forced into markup withMarkup::create()or|rawfrom user input are an XSS risk. -
Never assume the user is anonymous or trusted in cron.
hook_cronruns 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_saltand tokens come from environment variables viasettings.php, never from the module code or exported configuration. -
Mind performance as security.
hook_entity_presave,hook_form_alterandhook_preprocessrun 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 ( |
The hook runs twice |
A procedural function and a class method both implement it without |
The attribute value is |
The |
|
The method signature differs from the hook definition. Compare with the |
A function in |
Procedural hooks are collected only from the module’s main extension files, never from classes or arbitrary files. |
A hook defined inside an |
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
|
|
Install, update and schema hooks stay procedural in |
Status report entry disappeared after converting |
The legacy function was marked |
Order seems random between two modules |
Add an explicit |
A theme |
Themes do not support |
PHPStan reports |
|
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 |
11.1 |
Object-oriented hook implementations: |
11.2 |
|
11.3 |
Themes support OOP hooks (a subset, without ordering); legacy |
11.5 (in development) |
Deprecation of the |
12.0 |
|
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.
Related pages
-
Module anatomy and info.yml for the module layout in which
src/Hooklives. -
Events and subscribers for the Symfony event dispatcher, the other extension mechanism.
-
Services and dependency injection for autowiring and
#[Autowire]. -
Update API and post-updates for the hooks that stay procedural.
-
Entity API for the entity lifecycle hooks used above.
References
-
Change record: Support for object oriented hook implementations (11.1)
-
Change record: Hook implementations can now be ordered with an order parameter (11.2)
-
Change record: hook_module_implements_alter requires LegacyModuleImplementsAlter (11.2)
-
Change record: Order of procedural and object oriented hook implementations (11.2)
-
Change record: Procedural hooks are collected only from main extension files (12.0)
-
Change record: The .profile file extension is deprecated (11.5)