Drupal 11.2 introduces complete Object-Oriented Programming (OOP) support for hooks, eliminating the need for .module files in many cases. This guide covers the three key attributes that make this possible.
Overview
Starting with Drupal 11.2, you can implement ALL hooks using object-oriented methods with these attributes:
#[Hook()]- Define hook implementations on classes/methods#[RemoveHook()]- Remove hook implementations from other modules#[ReOrderHook()]- Change the execution order of hooks#[Hook('preprocess')]- Implement preprocess hooks in OOP style
Setting Up OOP Hooks
1. Directory Structure
Create your hook classes in the src/Hook/ namespace:
my_module/
├── src/
│ └── Hook/
│ ├── MyModuleHooks.php
│ ├── NodeHooks.php
│ └── ThemeHooks.php
├── my_module.info.yml
└── composer.json (no .module file needed!)
2. Basic Hook Class Structure
<?php
namespace Drupal\my_module\Hook;
use Drupal\Core\Hook\Attribute\Hook;
/**
* Hook implementations for my_module.
*/
class MyModuleHooks {
/**
* Implements hook_cron().
*/
#[Hook('cron')]
public function cron(): void {
// Your cron logic here
\Drupal::logger('my_module')->info('Cron executed via OOP hook!');
}
/**
* Implements hook_entity_presave().
*/
#[Hook('entity_presave')]
public function entityPresave($entity): void {
if ($entity->getEntityTypeId() === 'node') {
// Process nodes before saving
$entity->set('changed', time());
}
}
}
#[Hook()] Attribute - Basic Implementation
Method-Level Implementation
<?php
namespace Drupal\my_module\Hook;
use Drupal\Core\Hook\Attribute\Hook;
use Drupal\node\NodeInterface;
class NodeHooks {
/**
* Implements hook_node_presave().
*/
#[Hook('node_presave')]
public function nodePresave(NodeInterface $node): void {
// Set automatic title if empty
if (empty($node->getTitle())) {
$node->setTitle('Auto-generated title: ' . date('Y-m-d H:i:s'));
}
}
/**
* Implements hook_node_access().
*/
#[Hook('node_access')]
public function nodeAccess(NodeInterface $node, string $op, $account) {
// Custom access logic
if ($op === 'view' && $node->bundle() === 'private_content') {
return AccessResult::forbiddenIf(!$account->hasPermission('view private content'));
}
return AccessResult::neutral();
}
}
Class-Level Implementation
<?php
namespace Drupal\my_module\Hook;
use Drupal\Core\Hook\Attribute\Hook;
/**
* Multiple hook implementations can be declared at class level.
*/
#[Hook('form_alter', method: 'alterForms')]
#[Hook('node_insert', method: 'handleNodeInsert')]
#[Hook('user_login', method: 'handleUserLogin')]
class MultipleHooks {
public function alterForms(&$form, $form_state, $form_id): void {
if ($form_id === 'node_article_form') {
$form['title']['#required'] = FALSE;
}
}
public function handleNodeInsert($node): void {
\Drupal::logger('my_module')->notice('Node @title created', [
'@title' => $node->getTitle()
]);
}
public function handleUserLogin($account): void {
\Drupal::messenger()->addStatus('Welcome back, ' . $account->getDisplayName());
}
}
Using __invoke() Method
<?php
namespace Drupal\my_module\Hook;
use Drupal\Core\Hook\Attribute\Hook;
/**
* Single-purpose hook class using __invoke.
*/
#[Hook('cron')]
class CronHandler {
public function __invoke(): void {
// Clean up temporary files
$temp_files = \Drupal::entityTypeManager()
->getStorage('file')
->loadByProperties(['status' => 0]);
foreach ($temp_files as $file) {
if (time() - $file->getCreatedTime() > 86400) { // 24 hours
$file->delete();
}
}
}
}
#[Hook('preprocess')] - Preprocess Hooks
Drupal 11.2 now supports preprocess hooks in OOP style:
<?php
namespace Drupal\my_module\Hook;
use Drupal\Core\Hook\Attribute\Hook;
class ThemeHooks {
/**
* Implements hook_preprocess_page().
*/
#[Hook('preprocess', hook: 'page')]
public function preprocessPage(&$variables): void {
// Add custom variable to all pages
$variables['site_slogan'] = \Drupal::config('system.site')->get('slogan');
$variables['current_year'] = date('Y');
}
/**
* Implements hook_preprocess_node().
*/
#[Hook('preprocess', hook: 'node')]
public function preprocessNode(&$variables): void {
$node = $variables['node'];
// Add reading time estimation
if ($node->hasField('body')) {
$text = strip_tags($node->body->value ?? '');
$word_count = str_word_count($text);
$reading_time = ceil($word_count / 200); // 200 words per minute
$variables['reading_time'] = $reading_time;
}
}
/**
* Implements hook_preprocess_block().
*/
#[Hook('preprocess', hook: 'block')]
public function preprocessBlock(&$variables): void {
// Add custom CSS classes based on block plugin ID
$block = $variables['elements']['#block'];
$plugin_id = $block->getPluginId();
$variables['attributes']['class'][] = 'block--' . str_replace('_', '-', $plugin_id);
}
}
#[RemoveHook()] - Removing Other Module Hooks
Use this to prevent other modules' hooks from executing:
<?php
namespace Drupal\my_module\Hook;
use Drupal\Core\Hook\Attribute\RemoveHook;
use Drupal\layout_builder\Hook\LayoutBuilderHooks;
use Drupal\toolbar\Hook\ToolbarHooks;
class HookRemover {
/**
* Remove Layout Builder's help hook.
*/
#[RemoveHook('help',
class: LayoutBuilderHooks::class,
method: 'help'
)]
public function removeLayoutBuilderHelp(): void {
// This method can be empty - it's just declaring the removal
}
/**
* Remove toolbar's page_top hook to customize toolbar rendering.
*/
#[RemoveHook('page_top',
class: ToolbarHooks::class,
method: 'pageTop'
)]
public function removeToolbarPageTop(): void {
// We'll implement our own toolbar rendering instead
}
/**
* Remove a procedural hook by module name.
*/
#[RemoveHook('node_access', module: 'some_module')]
public function removeSomeModuleNodeAccess(): void {
// Remove some_module_node_access() implementation
}
}
#[ReOrderHook()] - Changing Hook Execution Order
Control when your hooks run relative to other modules:
<?php
namespace Drupal\my_module\Hook;
use Drupal\Core\Hook\Attribute\ReorderHook;
use Drupal\Core\Hook\Order\OrderBefore;
use Drupal\Core\Hook\Order\OrderAfter;
use Drupal\Core\Hook\Order\Order;
use Drupal\content_moderation\Hook\ContentModerationHooks;
use Drupal\workspaces\Hook\WorkspacesHooks;
class HookOrderManager {
/**
* Make Content Moderation run before Workspaces.
*/
#[ReorderHook('entity_presave',
class: ContentModerationHooks::class,
method: 'entityPresave',
order: new OrderBefore(['workspaces'])
)]
public function reorderContentModerationBeforeWorkspaces(): void {
// This ensures content moderation processes entities before workspaces
}
/**
* Make our module run first for form alterations.
*/
#[ReorderHook('form_alter',
class: MyModuleHooks::class,
method: 'formAlter',
order: Order::First
)]
public function makeFormAlterFirst(): void {
// Our form alterations will run before all other modules
}
/**
* Run after specific modules.
*/
#[ReorderHook('node_presave',
class: MyModuleHooks::class,
method: 'nodePresave',
order: new OrderAfter(['pathauto', 'metatag'])
)]
public function runAfterPathAndMeta(): void {
// Run after pathauto and metatag have processed the node
}
/**
* Complex ordering with multiple classes.
*/
#[ReorderHook('entity_type_alter',
class: MyModuleHooks::class,
method: 'entityTypeAlter',
order: new OrderBefore([
classesAndMethods: [
[SomeClass::class, 'someMethod'],
[AnotherClass::class, 'anotherMethod'],
]
])
)]
public function complexOrdering(): void {
// Run before specific class methods
}
}
Hook Ordering with Order Attribute
You can also control ordering directly in the #[Hook()] attribute:
<?php
namespace Drupal\my_module\Hook;
use Drupal\Core\Hook\Attribute\Hook;
use Drupal\Core\Hook\Order\Order;
use Drupal\Core\Hook\Order\OrderBefore;
use Drupal\Core\Hook\Order\OrderAfter;
class OrderedHooks {
/**
* Run this hook first.
*/
#[Hook('entity_type_alter', order: Order::First)]
public function entityTypeAlterFirst(&$entity_types): void {
// Runs before all other entity_type_alter hooks
}
/**
* Run this hook last.
*/
#[Hook('entity_type_alter', order: Order::Last)]
public function entityTypeAlterLast(&$entity_types): void {
// Runs after all other entity_type_alter hooks
}
/**
* Run before specific modules.
*/
#[Hook('entity_type_alter', order: new OrderBefore(['views', 'field']))]
public function entityTypeAlterBeforeViews(&$entity_types): void {
// Runs before views and field modules
}
/**
* Run after specific modules.
*/
#[Hook('entity_type_alter', order: new OrderAfter(['node', 'user']))]
public function entityTypeAlterAfterCore(&$entity_types): void {
// Runs after node and user modules
}
}
Complete Example: AI Content Module
Here's a practical example for your AI content agent:
<?php
namespace Drupal\ai_content\Hook;
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\node\NodeInterface;
/**
* Hook implementations for AI content generation.
*/
class AiContentHooks {
/**
* Implements hook_node_presave().
*
* Automatically generate content summary using AI.
*/
#[Hook('node_presave')]
public function nodePresave(NodeInterface $node): void {
if ($node->bundle() === 'article' && $node->hasField('field_ai_summary')) {
if (empty($node->get('field_ai_summary')->value)) {
$body = $node->get('body')->value ?? '';
if (!empty($body)) {
$summary = $this->generateAiSummary($body);
$node->set('field_ai_summary', $summary);
}
}
}
}
/**
* Implements hook_form_node_form_alter().
*
* Add AI content generation buttons to node forms.
*/
#[Hook('form_alter')]
public function formAlter(&$form, $form_state, $form_id): void {
if (strpos($form_id, 'node_') === 0 && strpos($form_id, '_form') !== FALSE) {
$form['ai_tools'] = [
'#type' => 'details',
'#title' => $this->t('AI Tools'),
'#group' => 'advanced',
'#weight' => -10,
];
$form['ai_tools']['generate_content'] = [
'#type' => 'button',
'#value' => $this->t('Generate AI Content'),
'#ajax' => [
'callback' => '::generateAiContent',
'wrapper' => 'node-form-wrapper',
],
];
}
}
/**
* Implements hook_preprocess_node().
*
* Add AI-generated metadata to node templates.
*/
#[Hook('preprocess', hook: 'node')]
public function preprocessNode(&$variables): void {
$node = $variables['node'];
if ($node->hasField('field_ai_tags') && !$node->get('field_ai_tags')->isEmpty()) {
$variables['ai_generated_tags'] = $node->get('field_ai_tags')->getValue();
}
// Add AI confidence score if available
if ($node->hasField('field_ai_confidence')) {
$confidence = $node->get('field_ai_confidence')->value;
$variables['ai_confidence'] = $confidence;
$variables['ai_confidence_class'] = $confidence > 0.8 ? 'high' : ($confidence > 0.5 ? 'medium' : 'low');
}
}
/**
* Remove default node help to show our AI-enhanced help.
*/
#[RemoveHook('help', module: 'node')]
public function removeNodeHelp(): void {
// We'll provide AI-powered contextual help instead
}
/**
* Implement our own help hook with AI suggestions.
*/
#[Hook('help')]
public function help($route_name, $route_match) {
switch ($route_name) {
case 'node.add':
return '<p>' . $this->t('Use the AI Tools section to automatically generate content, tags, and summaries.') . '</p>';
}
}
/**
* Make sure our AI processing runs before search indexing.
*/
#[ReorderHook('node_update',
class: AiContentHooks::class,
method: 'nodeUpdate',
order: new OrderBefore(['search'])
)]
public function ensureAiBeforeSearch(): void {
// Ensures AI content is processed before search indexing
}
/**
* Implements hook_node_update().
*
* Reprocess AI content when nodes are updated.
*/
#[Hook('node_update')]
public function nodeUpdate(NodeInterface $node): void {
if ($node->hasField('field_enable_ai') && $node->get('field_enable_ai')->value) {
// Queue the node for AI reprocessing
$queue = \Drupal::queue('ai_content_processing');
$queue->createItem(['nid' => $node->id()]);
}
}
/**
* Implements hook_cron().
*
* Process queued AI content generation tasks.
*/
#[Hook('cron')]
public function cron(): void {
$queue = \Drupal::queue('ai_content_processing');
while ($item = $queue->claimItem()) {
try {
$this->processAiContent($item->data);
$queue->deleteItem($item);
} catch (\Exception $e) {
\Drupal::logger('ai_content')->error('AI processing failed: @error', ['@error' => $e->getMessage()]);
}
}
}
private function generateAiSummary(string $content): string {
// Your AI API integration here
return 'AI-generated summary...';
}
private function processAiContent(array $data): void {
// Process AI content generation tasks
}
}
Benefits of OOP Hooks
- No .module files needed - Keep everything in organized classes
- Better IDE support - Full autocomplete and type checking
- Easier testing - Mock and test individual hook classes
- Cleaner organization - Group related hooks in logical classes
- Advanced control - Fine-grained ordering and removal of hooks
- Type safety - Use proper type hints and return types
Migration from Procedural Hooks
Before (Procedural):
// my_module.module
function my_module_node_presave($node) {
// Hook logic
}
function my_module_form_alter(&$form, $form_state, $form_id) {
// Form logic
}
After (OOP):
// src/Hook/MyModuleHooks.php
#[Hook('node_presave')]
public function nodePresave(NodeInterface $node): void {
// Same logic, better structure
}
#[Hook('form_alter')]
public function formAlter(&$form, $form_state, $form_id): void {
// Same logic, better structure
}
Important Notes
- Backward Compatibility: Procedural hooks still work alongside OOP hooks
- Module-only: OOP hooks work in modules, not themes (themes must stay procedural)
- Install Hooks Exception: Installation-related hooks must remain procedural
- Performance: OOP hooks have no performance penalty compared to procedural hooks
- Discovery: Hooks are discovered during cache rebuild, stored in compiled container
This new OOP hook system makes Drupal development more modern, organized, and maintainable while providing powerful new capabilities for hook management.
- Log in to post comments