The Death of hook_: How Drupal Finally Embraced Object-Oriented PHP (And Why It Changes Everything)

The Death of hook_: How Drupal Finally Embraced Object-Oriented PHP (And Why It Changes Everything)

2025.12.29
~12 min read
Drupal PHP OOP Developer Experience Architecture
Sharewith caption

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.

Comparison between procedural hooks and OOP hooks in Drupal

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.

Timeline of Drupal hook evolution from D7 to D11.3

Drupal 11.1: The Foundation

Released in late 2024, Drupal 11.1 introduced the core #[Hook] attribute with these capabilities:

FeatureDescription
#[Hook('hook_name')]Declare a method as a hook implementation
Method-level attributesApply to specific methods within a class
Class-level attributesApply to class with __invoke() method
Multiple hooks per methodStack multiple #[Hook] attributes
Auto-discoveryClasses in src/Hook/ are automatically registered
AutowiringServices injected via constructor without services.yml

Drupal 11.2: Power Tools

The second minor release (early 2025) brought essential control mechanisms:

AttributePurpose
#[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:

FeatureAvailability
#[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?

AspectOldNew
Service access\Drupal::config()Constructor injection
TestabilityMock global state… good luckInject mock ConfigFactory
OrganizationOne growing .module fileLogical class groupings
IDE supportMinimalFull autocomplete, type hints
DiscoverabilityGrep 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

TaskOld WayNew Way (11.2+)
Run hook firsthook_module_implements_alter() + array manipulation#[Hook('name', order: Hook::FIRST)]
Run hook lastComplex weight logic#[Hook('name', order: Hook::LAST)]
Remove other hookunset() in implements_alter#[RemoveHook(...)]
Reorder other hookRebuild 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:

FeatureModulesThemes
#[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 CategoryOOP Compatible?Notes
Most hooks✅ YesForm alter, entity ops, etc.
hook_install()❌ NoRuns before class autoloading
hook_uninstall()❌ NoSame reason
hook_schema()❌ NoDatabase bootstrap phase
hook_requirements()⚠️ PartialSome limitations

The Future: Procedural Deprecation

What’s the long-term trajectory? Based on Drupal’s evolution patterns:

VersionExpected Status
Drupal 11.xBoth supported, OOP recommended
Drupal 12.0Procedural deprecated
Drupal 13.0Procedural 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.