Nine Years of .module Files, the end of an Era

If you've worked on a Drupal site of any real complexity, you know the file. The 600-line .module file where mymodule_form_alter is actually doing six unrelated things. Where \Drupal::service() calls are scattered through procedural functions because dependency injection was never really on the table. Where you open the file to make a small change and spend the first two minutes scrolling through, finding what you need.
I've been writing these files for nine years and as of Drupal 11.1, I don't have to anymore.
What Changed
Drupal 11.1 introduced the #[Hook] attribute, letting you implement hooks as methods on autowired classes in src/Hook/. 11.2 added preprocess hook support and an order parameter for controlling execution sequence. 11.3 brought themes in. Procedural hooks are deprecated and the plan is to remove them entirely in a future major version.
That's three minor releases to completely replace a pattern that's been with us since Drupal 4 (RIP).
Before and After
Here's a hook_form_alter the old way:
// mymodule.module
use Drupal\Core\Form\FormStateInterface;
function mymodule_form_alter(&$form, FormStateInterface $form_state, $form_id) {
if ($form_id === 'node_article_edit_form') {
$messenger = \Drupal::service('messenger');
$current_user = \Drupal::currentUser();
if (!$current_user->hasPermission('edit any article content')) {
$form['field_sensitive']['#access'] = FALSE;
$messenger->addWarning('Some fields are hidden based on your role.');
}
}
}
And the same thing as an OOP hook:
// src/Hook/MyModuleFormHooks.php
namespace Drupal\mymodule\Hook;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Hook\Attribute\Hook;
use Drupal\Core\Messenger\MessengerInterface;
use Drupal\Core\Session\AccountProxyInterface;
class MyModuleFormHooks {
public function __construct(
protected readonly MessengerInterface $messenger,
protected readonly AccountProxyInterface $currentUser,
) {}
#[Hook('form_alter')]
public function formAlter(&$form, FormStateInterface $form_state, string $form_id): void {
if ($form_id === 'node_article_edit_form') {
if (!$this->currentUser->hasPermission('edit any article content')) {
$form['field_sensitive']['#access'] = FALSE;
$this->messenger->addWarning('Some fields are hidden based on your role.');
}
}
}
}
This is more lines but what you get is well worth it.
Three Things I Actually Like
1. Dependency Injection
This is the one that matters most. Inject the logger, the entity type manager, the messenger — through the constructor like any other service. No more \Drupal::service() littered through hook bodies and no more static calls that make your code untestable and opaque.
The class is autowired so you don't even need a services.yml entry. Create your class in src/Hook/, add the attribute, and Drupal finds it during container build.
If you've spent any time writing custom services or plugins in Drupal 9+, this pattern is already second nature. Huge win as we finally have it OOP.
2. One Concern Per Class
FormAlterHooks.php. NodeLifecycleHooks.php. CronHooks.php. Open the Hook/ directory and you can read the surface area of the module in five seconds.
Compare that to scrolling through a .module file where hook_cron sits between hook_form_alter and hook_entity_presave with no organisational logic beyond the order someone happened to add them. The .module file was never a good organisational unit but to play devils advocate - it was easy, and understandable which is something Drupal's learning curve has never leant itself to.
3. Real Unit Tests
Procedural hooks were technically testable but practically nobody did it. Loading a .module file in a test, calling a global function, mocking services through the static container — it was possible but so tedious that most teams just wrote kernel tests for everything or skipped testing hooks altogether.
A class with injected dependencies you can mock is testable in the way the rest of Drupal already is which as we go into the big wide world or AI, is even more valuable.
Few points to note
Method-Level Attributes, Not Class-Level
You can put #[Hook('form_alter')] on the class itself and use __invoke, but don't. Method-level attributes scale better - one class can listen to node_insert, node_update, and node_delete with a shared private method doing the actual work:
class NodeLifecycleHooks {
#[Hook('node_insert')]
#[Hook('node_update')]
public function onNodeSave(NodeInterface $node): void {
$this->invalidateRelatedCaches($node);
}
#[Hook('node_delete')]
public function onNodeDelete(NodeInterface $node): void {
$this->cleanupReferences($node);
}
private function invalidateRelatedCaches(NodeInterface $node): void {
// Shared logic
}
}
Class-level with __invoke boxes you into one hook per class. Method-level gives you a natural grouping mechanism. The hashbangcode article lands in the same place — and this is a live debate in the community worth watching.
One Class Per Concern, Not One Class Per Hook
Some tutorials suggest one class per hook but I think that's overkill. You end up with FormAlterHook.php, NodeInsertHook.php, NodeUpdateHook.php, NodeDeleteHook.php — rebuilding the .module file fragmentation problem at one abstraction level higher.
Group by what the hooks actually do. If three hooks all deal with cache invalidation when nodes change, they belong in NodeCacheHooks. If two hooks configure form behaviour for a specific feature, they belong in FeatureFormHooks.
The Order Parameter
In 11.2, the #[Hook] attribute gained an order parameter that replaces the old hook_module_implements_alter dance (thank god). Simple cases:
use Drupal\Core\Hook\Order;
#[Hook('form_alter', order: Order::First)]
public function earlyFormAlter(&$form, FormStateInterface $form_state, string $form_id): void {
// Runs before other implementations
}
And for precise control:
use Drupal\Core\Hook\OrderBefore;
#[Hook('form_alter', order: new OrderBefore(['other_module']))]
public function formAlter(&$form, FormStateInterface $form_state, string $form_id): void {
// Runs before other_module's implementation
}
There's also OrderAfter, ReorderHook for reordering other modules' hooks, and RemoveHook for disabling them entirely. It's a proper system now, not a workaround bolted onto module weights.
Migrations again
Don't rewrite working code willy nilly as procedural hooks still work and will continue working through all of Drupal 11 (and I'd say Drupal 12).
If you maintain a contrib module that needs to support pre-11.1, use #[LegacyHook] on the procedural function and route it to your OOP class. Both versions of Drupal stay happy.
For new code on greenfield 11.1+ projects, default to OOP. There's no reason to write a new .module hook in 2026.
For existing custom modules on a stable site, migrate when you're already in the file for another reason. Touching a hook_form_alter to add a field? Move the whole hook to a class while you're there. Don't create a migration sprint just for the sake of it.
The Last Piece Falling Into Place
Drupal has spent fifteen years pulling itself toward modern PHP. The entity system, the plugin system, the service container, Symfony components, typed configuration — all of it moved the platform forward. But every time you opened a .module file and wrote a procedural function with \Drupal::service() calls, you were back in 2010.
Happy and sad to say that times are changing. The .module file was the last thing that felt like legacy PHP and it's on its way out. For anyone who's been writing Drupal for long enough to remember when this seemed impossible — it's a warm moment.