For nearly two decades, the answer to “How do I add functionality to Drupal?” has been the same: “Create a function named hook_something() in your .module file.”
It was simple. It was consistent. It was… procedural spaghetti waiting to happen.
Then, in late 2024, Drupal 11.1 landed with a change that made long-time contributors stop in their tracks: object-oriented hook implementations using PHP 8 attributes.
One year later, with Drupal 11.3.1 now stable, I can confidently say: this is the biggest developer experience improvement since Drupal 8’s Symfony integration. And if you haven’t made the switch yet, you’re writing legacy code.
Why Should You Care?
Let me paint a picture that’ll feel painfully familiar.
You’re debugging a complex Drupal module. Hook implementations are scattered across three different .module files. You need to understand when hook_entity_presave() runs relative to hook_node_update(). The IDE can’t help you because everything is globally namespaced. And testing? You’ve given up on testing hooks. It’s just not worth the mocking nightmare.
Now imagine this instead:
namespace Drupal\my_module\Hook;
use Drupal\Core\Hook\Attribute\Hook;
use Drupal\Core\Entity\EntityTypeManagerInterface;
class EntityHooks {
public function __construct(
private readonly EntityTypeManagerInterface $entityTypeManager,
) {}
#[Hook('entity_presave')]
public function onEntityPresave($entity): void {
// Your logic here, with full IDE support
// and injected dependencies
}
}
This is real. This works today. And it changes everything.

The Journey: From 11.1 to 11.3
Before we dive deep into the code, let’s trace how this feature evolved. Understanding the timeline helps you know which features are available for your target Drupal version.

Drupal 11.1: The Foundation
Released in late 2024, Drupal 11.1 introduced the core #[Hook] attribute with these capabilities:
| Feature | Description |
|---|---|
#[Hook('hook_name')] | Declare a method as a hook implementation |
| Method-level attributes | Apply to specific methods within a class |
| Class-level attributes | Apply to class with __invoke() method |
| Multiple hooks per method | Stack multiple #[Hook] attributes |
| Auto-discovery | Classes in src/Hook/ are automatically registered |
| Autowiring | Services injected via constructor without services.yml |
Drupal 11.2: Power Tools
The second minor release (early 2025) brought essential control mechanisms:
| Attribute | Purpose |
|---|---|
#[RemoveHook('hook', ClassName::class, 'method')] | Remove another module’s hook implementation |
#[ReOrderHook('hook', ClassName::class, 'method')] | Change hook execution order |
These replaced the notoriously confusing hook_module_implements_alter() with declarative, explicit attributes.
Drupal 11.3: Themes Join the Party
The current stable (December 2025) extended OOP hooks to themes:
| Feature | Availability |
|---|---|
#[Hook] in themes | ✅ Supported |
Theme hook classes in src/Hook/ | ✅ Supported |
#[RemoveHook] in themes | ❌ Not available |
#[ReOrderHook] in themes | ❌ Not available |
Additionally, 11.3 delivered a 26-33% performance improvement – partially thanks to more efficient hook resolution.
Part 1: Your First OOP Hook
Let’s transform a common procedural hook into its modern equivalent.
The Old Way (Pre-11.1)
// my_module.module
/**
* Implements hook_form_alter().
*/
function my_module_form_alter(&$form, FormStateInterface $form_state, $form_id) {
if ($form_id === 'user_login_form') {
$form['#attributes']['class'][] = 'custom-login-form';
// Need a service? Global call it is...
$config = \Drupal::config('my_module.settings');
if ($config->get('show_welcome_message')) {
$form['welcome'] = [
'#markup' => '<p>Welcome back!</p>',
'#weight' => -100,
];
}
}
}
What’s wrong with this?
- Global
\Drupal::config()call (untestable) - No IDE type hints on
$form_state - Logic mixed with hook discovery
- Hard to unit test in isolation
- Everything in one growing file
The New Way (11.1+)
Step 1: Create the directory structure:
my_module/
├── src/
│ └── Hook/
│ └── FormHooks.php
└── my_module.info.yml
Step 2: Implement the hook class:
<?php
declare(strict_types=1);
namespace Drupal\my_module\Hook;
use Drupal\Core\Config\ConfigFactoryInterface;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Hook\Attribute\Hook;
/**
* Form-related hook implementations.
*/
final class FormHooks {
public function __construct(
private readonly ConfigFactoryInterface $configFactory,
) {}
/**
* Customizes the user login form.
*/
#[Hook('form_alter')]
public function alterLoginForm(array &$form, FormStateInterface $form_state, string $form_id): void {
if ($form_id !== 'user_login_form') {
return;
}
$form['#attributes']['class'][] = 'custom-login-form';
$config = $this->configFactory->get('my_module.settings');
if ($config->get('show_welcome_message')) {
$form['welcome'] = [
'#markup' => '<p>Welcome back!</p>',
'#weight' => -100,
];
}
}
}
What changed?
| Aspect | Old | New |
|---|---|---|
| Service access | \Drupal::config() | Constructor injection |
| Testability | Mock global state… good luck | Inject mock ConfigFactory |
| Organization | One growing .module file | Logical class groupings |
| IDE support | Minimal | Full autocomplete, type hints |
| Discoverability | Grep for “function my_module” | Navigate class hierarchy |
Part 2: Advanced Patterns
Multiple Hooks, One Method
Sometimes different hooks need the same logic:
#[Hook('user_login')]
#[Hook('user_logout')]
public function onAuthenticationChange(UserInterface $account): void {
$this->logger->info('Auth state changed for user @id', ['@id' => $account->id()]);
$this->cache->invalidateTags(['user:' . $account->id()]);
}
Class-Level Hooks with __invoke()
For simple, single-purpose hook classes:
<?php
namespace Drupal\my_module\Hook;
use Drupal\Core\Hook\Attribute\Hook;
#[Hook('cron')]
final class CronHandler {
public function __invoke(): void {
// Cron logic here
// This method is called automatically
}
}
The Order Attribute
Control execution order relative to other implementations:
#[Hook('entity_presave', order: Hook::FIRST)]
public function runFirst($entity): void {
// Guaranteed to run before other implementations
}
#[Hook('entity_presave', order: Hook::LAST)]
public function runLast($entity): void {
// Guaranteed to run after other implementations
}
Part 3: RemoveHook and ReOrderHook (11.2+)
These are your power tools for controlling other modules’ hooks.
Removing a Hook Implementation
Let’s say contrib_module implements hook_node_access() in a way that breaks your use case:
<?php
namespace Drupal\my_module\Hook;
use Drupal\Core\Hook\Attribute\RemoveHook;
use Drupal\contrib_module\Hook\NodeHooks;
// This attribute removes the contrib module's implementation entirely
#[RemoveHook('node_access', class: NodeHooks::class, method: 'checkAccess')]
final class AccessOverrides {
// No methods needed - the attribute does the work
}
Important: You can only remove OOP hook implementations this way. Legacy procedural hooks require different handling.
Reordering Hook Execution
Sometimes you need your hook to run after another module’s hook:
<?php
namespace Drupal\my_module\Hook;
use Drupal\Core\Hook\Attribute\Hook;
use Drupal\Core\Hook\Attribute\ReOrderHook;
use Drupal\other_module\Hook\EntityHooks;
#[ReOrderHook('entity_presave', class: EntityHooks::class, method: 'onPresave', order: Hook::LAST)]
final class EntityOperations {
#[Hook('entity_presave')]
public function onPresave($entity): void {
// This runs, then other_module's implementation runs LAST
}
}
Comparison: Old vs New Hook Ordering
| Task | Old Way | New Way (11.2+) |
|---|---|---|
| Run hook first | hook_module_implements_alter() + array manipulation | #[Hook('name', order: Hook::FIRST)] |
| Run hook last | Complex weight logic | #[Hook('name', order: Hook::LAST)] |
| Remove other hook | unset() in implements_alter | #[RemoveHook(...)] |
| Reorder other hook | Rebuild array order manually | #[ReOrderHook(...)] |
Part 4: Migration Strategy
Step 1: Add the LegacyHook Attribute
You don’t need to delete your .module file immediately. The #[LegacyHook] attribute tells Drupal 11.1+ to skip the procedural version:
// my_module.module
use Drupal\Core\Hook\Attribute\LegacyHook;
/**
* Implements hook_form_alter().
*/
#[LegacyHook]
function my_module_form_alter(&$form, FormStateInterface $form_state, $form_id) {
// This won't run on 11.1+ if an OOP version exists
// But WILL run on older Drupal versions
}
This gives you backward compatibility. The procedural hook runs on Drupal 10.x, while the OOP version runs on 11.1+.
Step 2: Create Parallel OOP Implementation
// src/Hook/FormHooks.php
namespace Drupal\my_module\Hook;
#[Hook('form_alter')]
public function alterForm(&$form, FormStateInterface $form_state, $form_id): void {
// Same logic, but with proper DI
}
Step 3: Test Both Paths
Run your test suite on both Drupal 10 and 11 environments. When you’re confident:
Step 4: Remove Procedural Hooks
Once you drop Drupal 10 support, remove the .module implementations entirely.
Part 5: Theme OOP Hooks (11.3+)
Drupal 11.3 brought the same capabilities to themes. Here’s how:
Directory Structure
my_theme/
├── src/
│ └── Hook/
│ └── ThemeHooks.php
├── templates/
├── my_theme.info.yml
└── my_theme.theme # Can be minimal or empty now
Example: Theme Hook Suggestions
<?php
namespace Drupal\my_theme\Hook;
use Drupal\Core\Hook\Attribute\Hook;
final class ThemeHooks {
#[Hook('theme_suggestions_page')]
public function pageSuggestions(array $variables): array {
$suggestions = [];
if ($node = \Drupal::routeMatch()->getParameter('node')) {
$suggestions[] = 'page__' . $node->bundle();
}
return $suggestions;
}
#[Hook('preprocess_node')]
public function preprocessNode(array &$variables): void {
$variables['custom_attribute'] = 'value';
}
}
Theme Limitations
Remember these constraints for themes:
| Feature | Modules | Themes |
|---|---|---|
#[Hook] | ✅ | ✅ |
#[RemoveHook] | ✅ | ❌ |
#[ReOrderHook] | ✅ | ❌ |
order parameter | ✅ | ❌ |
| Dependency injection | ✅ | ✅ |
Part 6: Testing OOP Hooks
This is where the paradigm shift really shines.
Before: Testing Procedural Hooks
// This was... painful
public function testFormAlter() {
// Somehow bootstrap enough of Drupal to call hook
// Mock global state
// Cry a little
$form = [];
my_module_form_alter($form, $form_state, 'user_login_form');
// Assert...
}
After: Testing OOP Hooks
public function testFormAlter(): void {
// Create mock
$configFactory = $this->createMock(ConfigFactoryInterface::class);
$config = $this->createMock(ImmutableConfig::class);
$config->method('get')
->with('show_welcome_message')
->willReturn(true);
$configFactory->method('get')
->with('my_module.settings')
->willReturn($config);
// Instantiate the class directly
$hooks = new FormHooks($configFactory);
// Call the method
$form = [];
$form_state = $this->createMock(FormStateInterface::class);
$hooks->alterLoginForm($form, $form_state, 'user_login_form');
// Assert
$this->assertArrayHasKey('welcome', $form);
}
The difference is night and day. No bootstrap. No global state. Just instantiate, inject mocks, call, assert.
What Hooks Can’t Be OOP?
Not everything can migrate:
| Hook Category | OOP Compatible? | Notes |
|---|---|---|
| Most hooks | ✅ Yes | Form alter, entity ops, etc. |
hook_install() | ❌ No | Runs before class autoloading |
hook_uninstall() | ❌ No | Same reason |
hook_schema() | ❌ No | Database bootstrap phase |
hook_requirements() | ⚠️ Partial | Some limitations |
The Future: Procedural Deprecation
What’s the long-term trajectory? Based on Drupal’s evolution patterns:
| Version | Expected Status |
|---|---|
| Drupal 11.x | Both supported, OOP recommended |
| Drupal 12.0 | Procedural deprecated |
| Drupal 13.0 | Procedural removed |
The message is clear: invest in OOP hooks now, or refactor everything later.
Quick Reference: Attribute Cheat Sheet
// Basic hook
#[Hook('entity_presave')]
public function onPresave($entity): void {}
// Multiple hooks, same method
#[Hook('user_login')]
#[Hook('user_logout')]
public function onAuthChange(UserInterface $account): void {}
// Control order
#[Hook('form_alter', order: Hook::FIRST)]
public function alterFormFirst(&$form): void {}
// Class-level hook (uses __invoke)
#[Hook('cron')]
final class CronHandler {
public function __invoke(): void {}
}
// Remove another module's hook (11.2+)
#[RemoveHook('node_access', class: OtherHooks::class, method: 'checkAccess')]
final class Overrides {}
// Reorder another module's hook (11.2+)
#[ReOrderHook('entity_presave', class: OtherHooks::class, method: 'onPresave', order: Hook::LAST)]
final class ControlOrder {}
// Mark procedural as legacy (for backward compat)
#[LegacyHook]
function my_module_form_alter() {}
Conclusion: The .module File Is Dead, Long Live OOP
Twenty years of hook_something() functions. Twenty years of growing .module files. Twenty years of avoiding proper testing because “hooks are too hard to mock.”
That era is over.
Drupal 11.1+ gives us what we’ve been asking for since Drupal 8 promised modern PHP:
- Real dependency injection in hooks
- Proper class organization
- IDE support that actually works
- Unit tests that don’t require a full bootstrap
If you’re starting a new module in 2025, there’s no excuse for procedural hooks. If you’re maintaining existing code, the #[LegacyHook] attribute gives you a clean migration path.
The developers who wrote that LinkedIn post a year ago were right to be excited. This is one of the biggest DX improvements since Drupal 8.
Now it’s your turn. Pick one hook in your module. Convert it. Feel the difference.
The future of Drupal is object-oriented. The .module file is optional. Welcome to the new era.
Have questions about migrating your hooks? Drop a comment or find me on LinkedIn. I’ve helped teams migrate thousands of hook implementations – happy to point you in the right direction.