Modifying links with hook_menu_links_discovered_alter()for Drupal 8 , 9 , 10 , and 11

Last updated :  

Every menu link you've built so far has been static — written once in YAML, the same for every visitor, every time. But sometimes a link needs to change based on something YAML can't know in advance: the current user's role, a config setting, the number of items in a queue. hook_menu_links_discovered_alter() is how you reach in and modify links — your own, or anyone else's — after they've been discovered but before they're rendered.

What you'll learn in this lesson

  • When hook_menu_links_discovered_alter() runs in the menu-building process, and why that timing matters
  • How to find the exact array key for the link you want to change
  • Real, common uses: dynamic titles, conditionally hiding a link, changing its target route

The shape of the hook

/**
 * Implements hook_menu_links_discovered_alter().
 */
function my_module_menu_links_discovered_alter(array &$links) {
  // Change an existing link's title dynamically.
  if (isset($links['my_module.dashboard'])) {
    $pending_count = \Drupal::database()
      ->select('my_module_queue', 'q')
      ->countQuery()
      ->execute()
      ->fetchField();

    $links['my_module.dashboard']['title'] = t('Dashboard (@count pending)', [
      '@count' => $pending_count,
    ]);
  }

  // Remove a link entirely for users who lack a specific permission.
  if (isset($links['my_module.admin_tools']) && !\Drupal::currentUser()->hasPermission('access my module tools')) {
    unset($links['my_module.admin_tools']);
  }
}

How it works

The single &$links parameter, and why it's passed by reference

This is an alter hook — the same family as hook_form_alter() from an earlier lesson. It receives one parameter, the entire array of every menu link discovered from every enabled module's *.links.menu.yml file, passed by reference (that's what the & means). You're not given a copy to inspect — you're handed the real, live array, and any change you make to it directly changes what actually gets rendered. There's no return value because there's nothing to return; the mutation itself is the whole effect.

Finding the right array key

Every link is keyed by its own machine name — the exact same string that's the top-level key in its .links.menu.yml definition. So a link declared as my_module.dashboard: in YAML shows up here as $links['my_module.dashboard'], containing every property that link was defined with (title, route_name, parent, and so on) as a plain associative array. You can alter any module's link this way, not just your own — which is exactly why this hook exists, rather than every module just editing its own YAML conditionally.

Two genuinely common uses

Overwriting title with a dynamically computed string (a live count, a username, anything YAML can't express statically) is the most common use by far — Drupal core itself does this for things like the "My account" link showing the current user's name. The second common use is conditional removal with unset(), exactly as shown above: hiding a link entirely based on a runtime check that a static _permission requirement on the route can't express, like a feature flag or a business-hours check.

This runs on every single request that needs menu data, not just once. Because of that, keep the logic inside cheap — a single lightweight query is fine, but anything expensive here becomes a site-wide performance cost. If you need something heavier, compute it once and cache it yourself (with appropriate cache tags so it invalidates correctly) rather than running it fresh on every page load.

See it for yourself

There's no single page to visit for this one — the effect is the menu link tree itself. After implementing this hook, run drush cr (menu link data is cached, just like the plain YAML-defined links from earlier lessons), then check the target menu and confirm your title change or removal took effect.

Quick check: you implement this hook, but forget to check isset($links['my_module.dashboard']) before modifying it — you just write straight to that key. What breaks, and when? Nothing breaks immediately if the link genuinely exists — but if your module is ever installed without the module that defines that link (or the link's machine name changes), you'll get an undefined-array-key warning, or worse, silently create a malformed new link entry with that key instead of altering the one you meant. Always guard with isset() first, exactly like the example above.

Key takeaways

  • hook_menu_links_discovered_alter(array &$links) runs after every module's static .links.menu.yml links are discovered, letting you modify any of them — not just your own.
  • Links are keyed by their own machine name, matching the top-level key from their YAML definition exactly.
  • The two most common uses are dynamic titles (a live count, a username) and conditional removal with unset() based on logic a static permission can't express.
  • Always guard with isset() before touching a specific link key — it may not exist if the module defining it isn't installed.
  • This runs on every request needing menu data — keep the logic cheap, or cache anything expensive yourself.

Coming up next

You've now covered every way Drupal offers to get a visitor from "somewhere on the site" to "your module's functionality" — static menu links, tabs, action buttons, and now runtime alterations to any of them. Next, the course turns to a different kind of asset your module can declare: CSS and JavaScript libraries, starting with how to define one.