Drupal Hooks — hook_entity_*, hook_form_alter and hook_theme Guide
In this tutorial, you'll learn how Drupal hooks work, how to implement the most important hooks including hook_entity_* for CRUD operations, hook_form_alter for modifying forms, hook_theme for registering templates, and hook_help for module documentation.
What You'll Learn
- What hooks are and how Drupal's hook system works
- hook_help: providing help text for your module
- hook_entity_type_build: defining custom entity types
- hook_entity_insert/update/delete/predelete: reacting to entity operations
- hook_form_alter: modifying any form with fields, validation, and submit handlers
- hook_form_FORM_ID_alter: targeting specific forms
- hook_theme: registering templates and defining template variables
- hook_menu_links_discovered_alter: modifying menu links
- hook_views_data: exposing custom data to Views
- hook_views_data_alter: modifying Views data
- hook_install and hook_uninstall: module installation and uninstallation tasks
- hook_schema: defining database tables
- hook_cron: scheduled tasks
- Implementation conventions: prefixing functions with module name
Why It Matters
Hooks are the backbone of Drupal's extensibility. Every contributed module uses hooks to integrate with Drupal's core systems without modifying core files. When you understand hooks, you can modify any behavior in Drupal — forms, entities, views, menus, themes, and more — without writing complex code. The hook system is what makes Drupal a Content Management Framework rather than just a CMS.
Real-World Use
A publishing site needs to automatically create a notification email whenever a new article is published. Using hook_entity_insert, the module detects when a node of type "article" is saved in published state and triggers an email to subscribers. Later, the team wants to add a field to all content edit forms. Using hook_form_alter, they add the field without modifying any core code. When the site is upgraded, these hooks continue to work because they do not override any files — they just react to events.
Learning Path
flowchart LR A[Custom Module Development] --> B[Drupal Hooks] B --> C[hook_entity_*] C --> D[hook_form_alter] D --> E[hook_theme] E --> F[hook_views_data] F --> G[Drupal API] G --> H[Advanced Module Development]
What Are Hooks?
A hook is a PHP function with a specific name that Drupal calls at defined moments. When you implement a hook, you tell Drupal: "At this point, run my code too."
The naming convention is MODULE_NAME_hook_name(). For example, if your module is called my_module and you want to implement hook_help, you create:
<?php
function my_module_help($route_name, $context) {
// Your help text here
}
Drupal discovers these functions by scanning the .module file. It calls every module's implementation of a hook whenever that hook is invoked.
How Hooks Work Internally
<?php
// This is what Drupal does internally when it needs
// to invoke a hook:
\Drupal::moduleHandler()->invokeAll('entity_insert', [$entity]);
// Every installed module with a function named
// MODULENAME_entity_insert() gets called.
// The $entity is passed as a parameter.
hook_help
Provide help text that appears on the module's help page:
<?php
/**
* Implements hook_help().
*/
function my_module_help($route_name, $context) {
switch ($route_name) {
case 'help.page.my_module':
$output = '';
$output .= '<h3>' . t('About My Module') . '</h3>';
$output .= '<p>' . t('My Module provides custom functionality for managing tasks and assignments.') . '</p>';
$output .= '<h3>' . t('Configuration') . '</h3>';
$output .= '<p>' . t('Configure at <a href=":url">the settings page</a>.', [
':url' => \Drupal\Core\Url::fromRoute('my_module.settings')->toString(),
]) . '</p>';
return $output;
case 'my_module.settings':
return '<p>' . t('Configure your API key and endpoint settings here.') . '</p>';
}
}
hook_entity_type_build
Define or alter entity types programmatically:
<?php
/**
* Implements hook_entity_type_build().
*/
function my_module_entity_type_build(array &$entity_types) {
// Add a custom entity type
$entity_types['task'] = new \Drupal\Core\Entity\ContentEntityType([
'id' => 'task',
'label' => t('Task'),
'label_collection' => t('Tasks'),
'label_singular' => t('task'),
'label_plural' => t('tasks'),
'handlers' => [
'storage' => 'Drupal\my_module\TaskStorage',
'list_builder' => 'Drupal\my_module\TaskListBuilder',
'form' => [
'default' => 'Drupal\my_module\Form\TaskForm',
'edit' => 'Drupal\my_module\Form\TaskForm',
'delete' => 'Drupal\my_module\Form\TaskDeleteForm',
],
'access' => 'Drupal\my_module\TaskAccessControlHandler',
],
'base_table' => 'tasks',
'entity_keys' => [
'id' => 'id',
'label' => 'title',
'uuid' => 'uuid',
'uid' => 'user_id',
],
]);
}
hook_entity_insert / update / delete / predelete
React to entity CRUD operations:
<?php
/**
* Implements hook_entity_insert().
*/
function my_module_entity_insert(\Drupal\Core\Entity\EntityInterface $entity) {
// Only act on article nodes
if ($entity->getEntityTypeId() === 'node' && $entity->bundle() === 'article') {
// Send notification when article is published
if ($entity->isPublished()) {
\Drupal::service('my_module.notifier')
->notifyNewArticle($entity);
}
}
}
/**
* Implements hook_entity_update().
*/
function my_module_entity_update(\Drupal\Core\Entity\EntityInterface $entity) {
if ($entity->getEntityTypeId() === 'node') {
\Drupal::logger('my_module')
->info('Node @title was updated.', ['@title' => $entity->getTitle()]);
}
}
/**
* Implements hook_entity_predelete().
*/
function my_module_entity_predelete(\Drupal\Core\Entity\EntityInterface $entity) {
// Archive content before deletion
if ($entity->getEntityTypeId() === 'node') {
\Drupal::service('my_module.archiver')
->archiveNode($entity);
}
}
hook_form_alter
Modify any form in Drupal — this is one of the most powerful hooks:
<?php
/**
* Implements hook_form_alter().
*/
function my_module_form_alter(&$form, \Drupal\Core\Form\FormStateInterface $form_state, $form_id) {
// Add a help message to all forms
$form['help_text'] = [
'#markup' => '<p class="help-message">Fields marked with * are required.</p>',
'#weight' => -100,
];
}
/**
* Implements hook_form_FORM_ID_alter().
* Targets only the node article edit form.
*/
function my_module_form_node_article_form_alter(&$form, \Drupal\Core\Form\FormStateInterface $form_state, $form_id) {
// Add a custom field to the article form
$form['editorial_notes'] = [
'#type' => 'textarea',
'#title' => t('Editorial Notes'),
'#description' => t('Internal notes for the editorial team.'),
'#weight' => 100,
];
// Add a custom submit handler
$form['#submit'][] = 'my_module_article_form_submit';
// Change the submit button text
$form['actions']['submit']['#value'] = t('Save and Review');
}
/**
* Custom submit handler for article form.
*/
function my_module_article_form_submit(&$form, \Drupal\Core\Form\FormStateInterface $form_state) {
$notes = $form_state->getValue('editorial_notes');
if (!empty($notes)) {
\Drupal::messenger()->addMessage(t('Editorial notes saved.'));
// Store notes in state or a custom field
\Drupal::state()->set('article_notes_' . $form_state->getFormObject()->getEntity()->id(), $notes);
}
}
hook_theme
Register templates for your module:
<?php
/**
* Implements hook_theme().
*/
function my_module_theme($existing, $type, $theme, $path) {
return [
'my_module_task_card' => [
'variables' => [
'title' => NULL,
'status' => NULL,
'priority' => NULL,
'due_date' => NULL,
],
'template' => 'task-card',
],
'my_module_task_list' => [
'variables' => [
'tasks' => [],
'empty_message' => 'No tasks found.',
],
'template' => 'task-list',
],
];
}
Then create the template file at templates/task-card.html.twig:
<div class="task-card priority-{{ priority|default('normal') }}">
<h3 class="task-card__title">{{ title }}</h3>
<div class="task-card__meta">
<span class="task-card__status">{{ status }}</span>
<span class="task-card__due-date">{{ due_date }}</span>
</div>
</div>
And use it in a controller:
<?php
public function taskDashboard() {
return [
'#theme' => 'my_module_task_card',
'#title' => 'Finish report',
'#status' => 'In Progress',
'#priority' => 'high',
'#due_date' => '2026-07-15',
];
}
hook_menu_links_discovered_alter
Modify existing menu links:
<?php
/**
* Implements hook_menu_links_discovered_alter().
*/
function my_module_menu_links_discovered_alter(&$links) {
// Change the weight of the "Add content" link
if (isset($links['node.add_page'])) {
$links['node.add_page']['weight'] = 5;
}
// Remove a specific menu link
unset($links['contact.site_page']);
}
hook_views_data
Expose custom data to Views:
<?php
/**
* Implements hook_views_data().
*/
function my_module_views_data() {
$data = [];
$data['tasks']['table']['group'] = t('Task Manager');
$data['tasks']['table']['base'] = [
'field' => 'id',
'title' => t('Tasks'),
'help' => t('Custom task data from My Module.'),
];
$data['tasks']['id'] = [
'title' => t('Task ID'),
'help' => t('The unique task identifier.'),
'field' => [
'id' => 'numeric',
],
'filter' => [
'id' => 'numeric',
],
];
$data['tasks']['title'] = [
'title' => t('Task Title'),
'help' => t('The task title.'),
'field' => [
'id' => 'standard',
],
'sort' => [
'id' => 'standard',
],
'filter' => [
'id' => 'string',
],
];
return $data;
}
hook_schema
Define database tables when your module is installed:
<?php
/**
* Implements hook_schema().
*/
function my_module_schema() {
$schema['tasks'] = [
'description' => 'Stores custom task data.',
'fields' => [
'id' => [
'type' => 'serial',
'unsigned' => true,
'not null' => true,
'description' => 'Primary key.',
],
'title' => [
'type' => 'varchar',
'length' => 255,
'not null' => true,
'description' => 'The task title.',
],
'status' => [
'type' => 'varchar',
'length' => 32,
'not null' => true,
'default' => 'pending',
'description' => 'Task status.',
],
'user_id' => [
'type' => 'int',
'unsigned' => true,
'not null' => true,
'description' => 'The user who created the task.',
],
'created' => [
'type' => 'int',
'unsigned' => true,
'not null' => true,
'description' => 'Timestamp of creation.',
],
],
'primary key' => ['id'],
'indexes' => [
'status' => ['status'],
'user_id' => ['user_id'],
],
];
return $schema;
}
hook_install and hook_uninstall
Run code when your module is installed or uninstalled:
<?php
/**
* Implements hook_install().
*/
function my_module_install() {
// Set default configuration
\Drupal::configFactory()
->getEditable('my_module.settings')
->set('sync_interval', 60)
->set('api_endpoint', 'https://api.example.com')
->save();
}
/**
* Implements hook_uninstall().
*/
function my_module_uninstall() {
// Clean up configuration
\Drupal::configFactory()
->getEditable('my_module.settings')
->delete();
// Remove state values
\Drupal::state()->delete('my_module.last_sync');
}
hook_cron
Run scheduled tasks:
<?php
/**
* Implements hook_cron().
*/
function my_module_cron() {
$last_sync = \Drupal::state()->get('my_module.last_sync', 0);
$interval = \Drupal::config('my_module.settings')
->get('sync_interval');
// Only sync if enough time has passed
if (\Drupal::time()->getRequestTime() - $last_sync >= $interval) {
try {
// Perform the sync operation
\Drupal::service('my_module.api_client')->syncData();
\Drupal::state()->set('my_module.last_sync', \Drupal::time()->getRequestTime());
\Drupal::logger('my_module')->info('Data sync completed successfully.');
} catch (\Exception $e) {
\Drupal::logger('my_module')->error('Sync failed: @message', [
'@message' => $e->getMessage(),
]);
}
}
}
Common Mistakes
Using wrong function name: The function must follow
MODULENAME_hookname()exactly. If your module ismy_custom_module, the function for form_alter must bemy_custom_module_form_alter(). Drupal scans for exact matches.Putting hooks in the wrong file: Most hooks go in the
.modulefile. However, some hooks likehook_themecan go in a.themefile for themes. Check the hook documentation for the correct file location.Not checking entity type or bundle in entity hooks:
hook_entity_insertfires for ALL entity types. Always check$entity->getEntityTypeId()and$entity->bundle()before acting, or you will affect all content types.Modifying form state incorrectly in hook_form_alter: Form state access differs between form alters and submit handlers. In
hook_form_alter, use$form_state->getValue(). In submit handlers, values are already processed.Forgetting to return values from hook_theme:
hook_thememust return an array. If you return nothing, no templates are registered, and your theme function fails silently.
Practice Questions
- What is the naming convention for a hook function in a module called
custom_tools? - How do you target only the user registration form with
hook_form_alter? - What is the difference between
hook_entity_insertandhook_entity_predelete? When would you use each? - Challenge: Create a module that uses at least six different hooks:
hook_help,hook_form_alterto add a field to the article form,hook_entity_insertto log new articles,hook_themeto register a custom template,hook_cronto clean up old log entries, andhook_schemato create a log table.
FAQ
Mini Project
Goal: Build a module that uses hooks to implement an editorial notification system.
- Create a module called
editorial_notify - Implement
hook_helpto provide documentation - Implement
hook_schemato create a tablenotifications_logwith columns: id, nid, action (insert/update/delete), timestamp - Implement
hook_entity_insertto log when any node is created - Implement
hook_entity_updateto log when a node's publishing status changes - Implement
hook_form_node_article_form_alterto add a "Notify subscribers" checkbox - Implement
hook_themeto register a template for the notification log display - Implement
hook_cronto clean up log entries older than 30 days - Create a controller that displays the notification log using your registered template
What's Next
Now that you understand hooks, proceed to Drupal API to learn about render arrays, entity queries, and the service container. Then explore Drush commands to manage Drupal from the command line.
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro