Drupal Alter Hooks: Modifying Forms with hook_form_alter()for Drupal 11 , and 10

Last updated :  

So far every hook you've met has been about gathering or reacting to something. This lesson introduces a fundamentally different category: the alter hook. An alter hook doesn't collect new information — it receives a data structure that's already been fully assembled by someone else, and gets a chance to change it, in place, before it's actually used. hook_form_alter(), which you're about to read, is one of the most widely implemented hooks in the whole Drupal ecosystem, because it lets any module reach into any form on the site and modify it — without ever touching that form's own code.

Think of it like editing a document someone else already wrote

Picture a colleague finishing a report and placing it in a shared tray marked "final draft — last chance to edit before it's printed." Anyone can walk up, cross something out, add a note in the margin, or change a heading — directly on that same physical piece of paper — before it goes to the printer. Nobody has to ask the original author for permission, and the author's own draft-writing process never has to know or care who else might scribble on it afterward.

That's exactly what &$form — passed by reference — means in an alter hook. You're not given a copy of the form to look at; you're handed the actual, live data structure, with permission to change it directly, right before Drupal renders it.

What you'll learn in this lesson

  • The alter hook pattern in general — why it's fundamentally different from a "normal" hook
  • What pass-by-reference (&$form) means, and why it's the mechanism that makes altering possible at all
  • How to safely target one specific form out of every form on the site, using $form_id
  • The more targeted variant, hook_form_FORM_ID_alter(), and when to prefer it

The source file

Path (relative to the Examples module's root): modules/hooks_example/hooks_example.module — the same file from the last two lessons, reproduced in full again; this time the explanation focuses on hooks_example_form_alter().

<?php

/**
 * @file
 * Examples demonstrating how to implement and invoke hooks.
 */

use Drupal\Core\Entity\Display\EntityViewDisplayInterface;
use Drupal\Core\Entity\EntityInterface;
use Drupal\Core\Form\FormStateInterface;
use Drupal\node\NodeInterface;
use Drupal\Core\Routing\RouteMatchInterface;

/**
 * @defgroup hooks_example Example: Hooks
 * @ingroup examples
 * @{
 * Demonstrates implementing, defining, and invoking hooks.
 *
 * Knowing how to implement, define, and invoke hooks is a critical concept for
 * any Drupal developer.
 *
 * Hooks are specially named functions called at key points in order to allow
 * other code to alter, extend, and enhance the behavior of Drupal core, or
 * another module. Without requiring changes to the original code.
 *
 * Every hook has three parts; a name, an implementation, and a definition.
 *
 * Hooks are implemented by following the function naming convention and
 * reviewing the documentation associated with a hook to discover parameters and
 * their expected values. Learn how to implement hooks by reviewing
 * hooks_example_help(), hooks_example_node_view(), and
 * hooks_example_form_alter() below.
 *
 * Because the list of hook implementations is cached you'll need to clear the
 * cache when first adding a new hook implementation.
 *
 * Hooks are defined by creating a new, unique, hook name, providing
 * documentation for the hook in an {MODULE_NAME}.api.php file, and using either
 * \Drupal\Core\Extension\ModuleHandlerInterface::invokeAll(),
 * \Drupal\Core\Extension\ModuleHandlerInterface::invoke(), or
 * \Drupal\Core\Extension\ModuleHandlerInterface::alter() via the
 * 'module_handler' service to call implementations of a hook in all enabled
 * modules. Learn how to define, and invoke a new hook by reviewing
 * hooks_example_node_view().
 *
 * Learn how to document a hook by reviewing hooks_example.api.php.
 *
 * @link https://www.drupal.org/docs/8/creating-custom-modules/understanding-hooks
 * Understanding hooks @endlink
 *
 * In order to see this example module in action you should create one or more
 * nodes on your site. Then visit those nodes and look for the view counter
 * added by this module. In addition, look for the special message displayed at
 * the top of a node the first time you view it.
 *
 * @see hooks
 * @see \Drupal\Core\Extension\ModuleHandlerInterface
 */

/**
 * Implements hook_help().
 *
 * When implementing a hook you should use the standard text "Implements
 * HOOK_NAME." as the docblock for the function. This is an indicator that
 * further documentation for the function parameters can be found in the
 * docblock for hook being implemented and reduces duplication.
 *
 * This function is an implementation of hook_help(). Following the naming
 * convention for hooks, the "hook_" in hook_help() has been replaced with the
 * short name of our module, "hooks_example_" resulting in a final function name
 * of hooks_example_help().
 */
function hooks_example_help($route_name, RouteMatchInterface $route_match) {
  switch ($route_name) {
    // For help overview pages we use the route help.page.$moduleName.
    case 'help.page.hooks_example':
      return '<p>' . t('This text is provided by the function <code>hooks_example_help()</code>, which is an implementation of <code>hook hook_help()</code>. To learn more about how this works checkout the code in <code>hooks_example.module</code>.') . '</p>';
  }
}

/**
 * Implements hook_ENTITY_TYPE_view().
 *
 * Some hook names include additional tokens that need to be replaced when
 * implementing the hook. These hooks are dynamic in that when they are being
 * invoked a portion of their name is replaced with a dynamic value. This is
 * indicated by placing the token words in all caps. This pattern is often used
 * in situations where you want to allow modules to generically act on all
 * instances of a thing, or to act on only a specific subset.
 *
 * There are lots of different entity types in Drupal. Node, user, file, etc.
 * Using hook_entity_view() a module can act on a any entity that is being
 * viewed, regardless of type. If we wanted to count views of all entities,
 * regardless of type this would be a good choice. This variant is also useful
 * if you want to provide administrators with a form where they can choose from
 * a list of entity types which ones they want to count views for. The logic in
 * the generic hook implementation could then take that into account and act on
 * only a select set of entity types.
 *
 * If however, you know you only ever want to act on viewing of a node entity
 * you can instead implement hook_ENTITY_TYPE_view(). Where ENTITY_TYPE is a
 * token that can be replaced with any valid entity type name.
 *
 * @see hook_entity_view()
 * @see hook_ENTITY_TYPE_view()
 */
function hooks_example_node_view(array &$build, EntityInterface $entity, EntityViewDisplayInterface $display, $view_mode) {
  // This example hook implementation keeps track of the number of times a user
  // has viewed a specific node during their current session. Then displays that
  // information for them when they view a node.
  //
  // In addition, a hook is invoked that allows other modules to react when the
  // page view count is updated.
  //
  // Retrieve the active session from the current request object.
  $session = \Drupal::request()->getSession();
  $current_counts = $session->get('hooks_example.view_counts', []);
  if (!isset($current_counts[$entity->id()])) {
    // If this is the first time they've viewed the page we need to start the
    // counter.
    $current_counts[$entity->id()] = 1;
  }
  else {
    // If they have already viewed this page just increment the existing
    // counter.
    $current_counts[$entity->id()]++;
  }

  // Save the updated values.
  $session->set('hooks_example.view_counts', $current_counts);

  // Invoke a hook to alert other modules that the count was updated.
  //
  // Hooks are invoked via the `module_handler` service. Which is an instance of
  // \Drupal\Core\Extension\ModuleHandlerInterface.
  //
  // Hooks can be invoked in a few different ways:
  // - All at once using ModuleHandlerInterface::invokeAll() to call all
  //   implementations of the specified hook provided by any enabled module.
  // - One at a time using ModuleHandlerInterface::invoke() to call only the
  //   the specified module's implementation of a hook.
  // - Using ModuleHandlerInterface::alter() to pass alterable variables to
  //   hook_TYPE_alter() implementations for all enabled modules. This method
  //   should be used for instances where the calling module has assembled data
  //   and would like to give other modules an opportunity to alter that data
  //   before it's used. A common pattern is to use invokeAll() to first gather
  //   input from other modules, the immediately afterwards call alter() to give
  //   modules the opportunity to alter the aggregate data.
  $module_handler = \Drupal::moduleHandler();

  // Calling \Drupal\Core\Extension\ModuleHandlerInterface::invokeAll() will
  // call implementations of the hook in question for all enabled modules. The
  // method takes two arguments. The name of the hook to invoke, and an optional
  // array of arguments to pass to any functions implementing the hook.
  //
  // Hook names need to be unique. So when defining a new hook in your module it
  // is customary to prefix the hook name with the short name of your module
  // followed by the descriptive name of the hook itself. Because hooks names
  // are also PHP function names they should contain only lowercase alphanumeric
  // characters and underscores.
  //
  // The hook name parameter should have the "hook_" prefix removed. If you want
  // to invoke hook_user_login(), the value used here would be 'user_login'.
  //
  // Hook implementations can optionally return a value, depending on the hook
  // definition. If they do, the invokeAll() method aggregates the responses
  // from all hooks in an array and returns the array.
  //
  // In this example we're invoking hook_hooks_example_count_incremented() and
  // passing all implementations the current view count for the node, and the
  // node object itself.
  $module_handler->invokeAll('hooks_example_count_incremented', [$current_counts[$entity->id()], $entity]);

  // Display the current number of pages the user has viewed along with the
  // node's content.
  $build['view_count'] = [
    '#markup' => '<p>' . t('You have viewed this node @total times this session.', ['@total' => $current_counts[$entity->id()]]) . '</p>',
    // In order for this example to work we disable caching for the content of
    // this node completely. This ensures that our hook is called every time the
    // node is viewed instead of using a cached version of the page for
    // subsequent requests.
    '#cache' => [
      'max-age' => 0,
    ],
  ];
}

/**
 * Implements hook_form_alter().
 */
function hooks_example_form_alter(&$form, FormStateInterface $form_state, $form_id) {
  // This is an example of what is known as an alter hook. The $form parameter
  // in this case represents an already complete Form API array and our hook
  // implementation is being given the opportunity to make changes to the
  // existing data structure before it's used. Invoking an alter hooks is a
  // common pattern anytime lists or complex data structures are assembled.
  // hook_form_alter(), which allows you to manipulate any form, is one of the
  // most commonly implemented hooks.
  //
  // @see hook_form_alter()
  // @see hook_form_FORM_ID_alter()
  //
  // If this is the user login form, change the description text of the username
  // field.
  if ($form_id === 'user_login_form') {
    $form['name']['#description'] = t('This text has been altered by hooks_example_form_alter().');
  }
}

/**
 * Implements hook_hooks_example_count_incremented().
 *
 * Hooks can be implemented by both the module that invokes them like we are
 * doing here, as well as by any other enabled module.
 */
function hooks_example_hooks_example_count_incremented($current_count, NodeInterface $node) {
  if ($current_count === 1) {
    \Drupal::messenger()->addMessage(t('This is the first time you have viewed the node %title.', ['%title' => $node->label()]));
  }
}

/**
 * @} End of "defgroup hooks_example".
 */

How it works

The alter hook pattern: standard hooks collect, alter hooks modify

A standard hook (like the hooks_example_node_view() you read last lesson) is invoked to collect new information from every enabled module. An alter hook is invoked to modify a data structure that's already been assembled. The unmistakable signal that you're looking at an alter hook is the & in the function signature — Drupal passes the data by reference, so any change your code makes is a change to the real, live structure, not to some throwaway copy.

Function signature of hooks_example_form_alter()

function hooks_example_form_alter(&$form, FormStateInterface $form_state, $form_id) {
  • &$form — the complete Form API render array for whichever form is currently being built, passed by reference. Any change you make here directly modifies the form that gets rendered.
  • $form_state — an instance of FormStateInterface, holding the form's current state: submitted values, validation errors, and anything stored across a multi-step flow. Even a simple alter often doesn't need it, but it's always available.
  • $form_id — a plain string identifying exactly which form is currently being built, like 'user_login_form' or 'node_article_edit_form'. This is your targeting mechanism.

Why the $form_id check is not optional

if ($form_id === 'user_login_form') {
  $form['name']['#description'] = t('This text has been altered by hooks_example_form_alter().');
}

hook_form_alter() fires for every single form on the entire site — every content edit form, every configuration form, every block placement form, all of them, one after another. Without the if ($form_id === ...) guard, this code would run — and potentially misbehave — on every form Drupal ever builds. Guarding by $form_id is the difference between a targeted, safe alteration and a module that silently breaks unrelated parts of the site. You can discover a form's $form_id by reading its form class's getFormId() method, or by using the Devel module to inspect it live.

Common beginner mistake: forgetting the $form_id guard entirely, or getting the exact string wrong (form IDs are case-sensitive and often longer than you'd guess, e.g. node_article_edit_form rather than just article). If your alter hook seems to do nothing, print or log $form_id temporarily to confirm you've got the exact right value.

Modifying a form element's property

$form['name']['#description'] = t('This text has been altered by hooks_example_form_alter().');

Form elements are render arrays, and their properties are prefixed with #. Common ones you'll modify in an alter hook include #title, #description, #default_value, #required, and #access (whether the field is visible at all). Here, $form['name'] is the username field on the login form, and this single line overwrites its #description property. Notice you're not rebuilding anything — you're simply reassigning one value inside an array that already exists.

hook_form_FORM_ID_alter() — the more targeted variant

The code's own comments reference hook_form_FORM_ID_alter(), a more specific variant that fires only for one particular form — the if check is baked into the function name itself instead of written by hand. To replicate the example above using this variant, the function would be named:

function hooks_example_form_user_login_form_alter(&$form, FormStateInterface $form_state, $form_id) {
  $form['name']['#description'] = t('This text has been altered.');
}

This is generally the better choice once you know exactly which form you're targeting — it avoids the (small but real) overhead of your code running on every unrelated form on the site, and it makes your intent obvious from the function name alone.

Quick check: if this code used hook_form_user_login_form_alter() instead of the generic hook_form_alter() with an if check, would you still need the $form_id parameter in the function signature? (Yes — the parameter is still passed to every form-alter hook regardless of variant; you'd just no longer need to check its value yourself, since the targeted variant only ever gets called for that one form.)

See it for yourself

Visit /user/login on your DDEV site. Look at the description text below the Username field — it now reads "This text has been altered by hooks_example_form_alter()." instead of Drupal's default text.

The Drupal login form with the username field description text altered by a custom module

Key takeaways

  • Alter hooks receive a data structure by reference (the & before $form) — any change to the variable directly changes the original, no return value needed.
  • hook_form_alter() fires for every form on the site; always guard with if ($form_id === 'your_target_form_id') to avoid unintended side effects.
  • Form elements are render arrays; their properties are prefixed with # (#description, #title, #required, #access) and can be overwritten like any PHP array value.
  • The targeted variant hook_form_FORM_ID_alter() is preferred once you know exactly which form to change — it only ever fires for that one form.
  • Wrap user-facing strings in t() so they're registered with Drupal's translation system.

Coming up next

You've now implemented a standard hook, invoked a custom one, and altered an existing form. The final lesson in this topic returns to this same file one last time — hooks_example_hooks_example_count_incremented(), which demonstrates something you might not expect: a module implementing the very hook it defines itself.