Hooks Best Practices
When to Use
You are writing or reviewing a hook class and want the standards a senior reviewer applies: injection, class shape, naming, file placement, security in alter hooks, and the failure modes that only show up in production.
Development standards
| Do | Not | Why |
|---|---|---|
| Inject through the constructor | \Drupal::service() inside a hook method |
The class is already an autowired service. Static calls hide dependencies and block unit testing. core/themes/olivero/src/Hook/OliveroPagePreprocessHooks.php injects four services; core/themes/claro/src/Hook/ClaroHooks.php still uses \Drupal::service() in pageAttachmentsAlter() and is the shape to move away from |
final class with private readonly promoted properties |
Open class with public properties | core/modules/system/src/Hook/PageAttachmentsHook.php is final with private readonly. A hook class has no consumers that should extend it. Core also writes non-final classes with protected readonly (NodeHelpHooks); choose final unless you designed for extension |
One method per hook, named for the hook: entityPresave(), formAlter(), preprocessHtml() |
A generic handle() that switches on a string |
The method name and the Implements hook_X(). docblock are how a reader finds the implementation; the attribute alone is not searchable by hook name in an IDE symbol index |
| One class per concern: form hooks, entity hooks, theme hooks, requirements | One MyModuleHooks with forty methods |
Core's node module ships fourteen hook classes. See https://camoa.github.io/dev-guides/drupal/solid-principles/hook-classes-srp/ |
| Helpers as private methods on the hook class or a service | Helpers left in .module |
A live .module forces the procedural scan unless you stop it, and the plan to deprecate .module is open (issue 3481555). For themes the .theme file is already deprecated in 11.5.0 |
Return types on every method (void, array, ?string, AccessResultInterface) |
Untyped methods | Core's converted hooks carry return types; invokeAll() merges array results with NestedArray::mergeDeep() and appends scalars, so a wrong return type corrupts the merged result silently |
Security
- Alter hooks run for every request and every user.
hook_form_alter(),hook_entity_access()alterations, andhook_ENTITY_TYPE_access()change behavior globally. Never set#access = TRUEor returnAccessResult::allowed()from an alter or access hook without a condition that names the permission or ownership being granted. Patterns: https://camoa.github.io/dev-guides/drupal/security/accessresult-patterns/, https://camoa.github.io/dev-guides/drupal/security/entity-access-control/ module:grants your code another module's identity. An implementation registered on behalf ofother_moduleis whatinvoke('other_module', $hook)runs. Use it only to fill a hook the other module is expected to provide, never to override its behavior; that is what#[ReorderHook]and#[RemoveHook]are for.#[RemoveHook]can remove a security control. Removing another module'sentity_accessorform_alterimplementation removes whatever that implementation enforced. Treat it like a core patch in review.- Install requirements run before the container exists.
InstallRequirementsInterface::getRequirements()is static and uninjected. Do not read user input or the request there.
Performance
- Discovery cost is at container build, not runtime.
HookCollectorPassparses every.module,.inc,.install,.profileof every module unless stopped. Setskip_procedural_hook_scanwhen a module is fully converted; see Procedural Scan Performance. - A hook class is a service; its constructor runs whenever the container instantiates it. Every injected dependency is built with it. Group hooks that fire on every page (
page_attachments,preprocess_html) into a lean class, and keep entity-save hooks with their heavier dependencies in another. hook_preprocess_HOOK()and cache metadata belong to the render layer. Variables added in preprocess need their cacheability bubbled; see https://camoa.github.io/dev-guides/drupal/twig/preprocess-functions/ and https://camoa.github.io/dev-guides/drupal/caching/cache-metadata-trinity/.hasImplementations()is cheap,invokeAll()is not. Guard expensive branches withhasImplementations('my_hook')the waycore/modules/node/src/Hook/NodeRequirements.phpguardsnode_grants.
Common Mistakes
- Double execution. A converted hook plus an unmarked procedural function both run on 11.1+. Symptoms: duplicated messages, doubled mail,
entity_presaveside effects applied twice. Fix:#[LegacyHook]on the function or delete it. - Double requirements on 11.2.
#[LegacyRequirementsHook]is not recognized in 11.2, so a module supporting 11.2 that ships bothhook_requirements()andhook_runtime_requirements()reports every requirement twice on that version. The doubling lasts only while the module also supports cores below 11.2 and so must keephook_requirements(); accept it for that window, or raise the minimum to 11.2 and deletehook_requirements(). - Ordering by module weight. Changing
system.schemaweights to move one hook moves every hook the module implements. Useorder:on the one implementation. Order::Firstas a default. Two modules both asking for first produce an undefined winner. Order relative to the module you actually depend on withOrderBefore/OrderAfter.- Hooks in
.incfiles included by hand. They are collected in 11.4.5 and silently ignored from 12.0.0 (change record 3613767). Convert them. - A hook class that is also a service in
services.ymlwith a different id. The collector registers the class name as the id only when no definition exists. Two ids for one class means two instances and two sets of injected state.
See Also
- ← Hooks Deprecation Timeline
- Dependency injection in general: https://camoa.github.io/dev-guides/drupal/services/constructor-injection/, https://camoa.github.io/dev-guides/drupal/services/drupal-global-helper/
- Hooks vs events for extension points you own: https://camoa.github.io/dev-guides/drupal/solid-principles/hooks-events-ocp/
- Testing: https://camoa.github.io/dev-guides/drupal/tdd/testing-events-hooks/