Menus and Path Aliases

This content was generated with the assistance of AI and should be verified against the official Drupal documentation before being relied on in production.

This sub-section’s Drupal bibliography lists the reference material consulted while preparing these pages.

Two systems decide how a Drupal visitor finds content. Menus are ordered, nested lists of links that the site builder maintains and that blocks render as navigation. Path aliases replace the internal address of an entity (/node/12) with a readable one (/exhibitions/dinosaur-hall) and are resolved on every request. This page, for site builders, editors, administrators and module developers, builds the navigation and the address scheme of the fictitious Meridian Museum site: a main navigation (Visit, Exhibitions, Events, Collection, About), a footer menu, a Pathauto pattern per content type, and redirects that keep old addresses alive.

It was checked on 2026-10-30 against Drupal 11.4.x, PHP 8.3+ and Drush 13/14 (Drush 13 for Drupal 10.2 and 11, Drush 14 for 11.3 and 12). Of the contributed modules, Pathauto 8.x-1.15 (released 2026-05-04) declares compatibility with Drupal ^10.2 and ^11, and Redirect 8.x-1.13 (released 2026-04-24) with ^10 and ^11; neither project page states Drupal 12 compatibility yet, so check both before upgrading (see Preparing for Drupal 12). Drupal 10 reaches end of life on 2026-12-09, so a new site starts on Drupal 11. Content types themselves are covered in Content Types and Fields, regions and block placement in Blocks and Block Layout, and translated aliases in Multilingual.

The pieces and who owns them

Module Where it lives What it provides

System (menu config entity)

Core, always on

The menu configuration entity (system.menu.main), the menu link plugin manager, the menu tree service and the system_menu_block block plugin.

Menu UI (menu_ui)

Core, optional

The screens under Structure > Menus (/admin/structure/menu), the "Menu settings" box on content forms and the per-content-type menu settings. Permission administer menu.

Menu Link Content (menu_link_content)

Core, optional

The menu_link_content content entity: links created by editors and stored in the database, exportable as content, translatable.

Path (path)

Core, optional

The "URL alias" widget on content forms and the URL aliases screen (/admin/config/search/path). Permissions create url aliases and administer url aliases.

Path Alias (path_alias)

Core, required

The path_alias content entity, the alias manager and repository, and the inbound and outbound path processors. It has no screens of its own and cannot be uninstalled.

Pathauto (pathauto)

Contributed, needs Token

Pattern-based alias generation for content, terms and users.

Redirect (redirect)

Contributed

The redirect entity, redirects from a changed alias, route normalisation (the job the older Global Redirect module did), and the optional Redirect 404 module that logs missing paths.

Menu links come from two sources. Static links are plugins declared in a module’s *.links.menu.yml; they are code, they appear in the Administration menu and the Tools menu, and an administrator can only change a few properties of them (title, parent, weight, enabled, expanded) through the Menu UI, because the changes are stored as overrides. Content links are menu_link_content entities created on the "Add link" form or the content edit form. Both are the same kind of object to the menu system (a menu link plugin, with the plugin ID menu_link_content: followed by the entity UUID for a content link), which is why the tree, the blocks and the breadcrumb treat them identically.

Menus

The menus a site starts with

The Standard profile creates five menus. A Minimal install creates only the Administration and User account menus that the toolbar needs; menus are configuration, so a site that starts from a recipe or a site template receives the menus that recipe defines.

Menu (machine name) Edited by Content

Main navigation (main)

Site builders

The links that visitors use to move around; new content menu links go here by default.

Footer (footer)

Site builders

Legal and contact links for the footer region.

Tools (tools)

Modules

Links modules offer to visitors; also the default target of a module link that names no menu_name.

User account menu (account)

Modules

My account, Log in and Log out.

Administration (admin)

Modules

Every administrative page; displayed by the Toolbar module or by the contributed Admin Toolbar.

The Drupal User Guide states that a menu link is shown only to visitors who have access to the page it points to, and that a menu is displayed by placing its block in a region. The guide’s menu concept page was last updated on 2025-04-14 and still names the Bartik theme; in current Drupal the front-end theme is Olivero (which leaves core in Drupal 12), and the Standard profile no longer creates the Article and Page content types since 11.4, so a tutorial that adds "the About page" to the menu must first create a content type.

To add a menu, open Structure > Menus > Add menu and give it a label, a machine name (meridian-visit is fine; the machine name is immutable) and a description. The menu is a system.menu.<name> configuration object, so it travels through configuration management (see Configuration Management). Its links, if they are content links, are content: they are not in the configuration export, so a link created on production does not appear after a configuration import on another environment. Static links, by contrast, always exist wherever the module is installed.

There are three ways for an editor to create a content link:

  • From the menu: Structure > Menus > Edit menu > Add link. The form asks for the link title, the target (an autocomplete of content titles, an internal path such as /visit, an external URL, or <front>, <nolink> and <button> for items that only group children or act as a button), a description (the tooltip), "Show as expanded", the parent link, and the weight.

  • From the content form: the "Menu settings" box on the right, as in the Drupal User Guide chapter on adding a page to the navigation. It offers the link title, a description, the parent link and the weight. The box appears only for content types that allow at least one menu.

  • In code or in a migration, creating MenuLinkContent entities (shown later).

The "Show as expanded" box matters to themes: the menu block shows the children of an expanded link even if the link is not in the active trail, and the Olivero main menu uses it to decide which submenus open on hover. Unchecking "Enabled" hides the link without deleting it.

Choosing which menus a content type offers

The "Menu settings" box on the content form is controlled per content type under Structure > Content types > Exhibition > Edit > Menu settings: the available menus (checkboxes) and the default parent item. They are stored as third-party settings of the content type, a schema defined by Menu UI (available_menus, a list of menu machine names, and parent, a string such as main:).

# config/sync/node.type.exhibition.yml (excerpt)
langcode: en
status: true
dependencies:
  module:
    - menu_ui
third_party_settings:
  menu_ui:
    available_menus:
      - main
    parent: 'main:meridian_collection.exhibitions'
name: Exhibition
type: exhibition
description: 'A temporary or permanent exhibition with dates, gallery and hero media.'

The default parent is written as <menu machine name>:<plugin ID of the parent link>, so main: alone means the top level of the main menu. If the parent plugin ID no longer exists (a static link was renamed, a content link was deleted), the form silently falls back to the top level; keep the string in step with the links you actually have.

Ordering and nesting

Structure > Menus > Edit menu lists the links as a drag-and-drop tree. Dragging a row changes its weight and parent; "Show row weights" exposes the numbers (lower weights first) for keyboard and assistive technology users, as described in the User Guide page on changing the order of navigation. Sorting is by weight, then title, then plugin ID. Internally the tree manipulator builds a key from the weight (offset so that negative and positive weights sort alike), the translated title and the plugin ID, which is why two links with the same weight appear in alphabetical order, in the language of the current page.

Keep menus shallow. Two levels cover most museum sites, and the Menu system’s maximum depth is limited by the storage (MenuLinkTree::maxDepth() returns the storage limit, nine levels in the default menu_tree table), which a deep Exhibitions hierarchy should never approach.

Menu blocks, regions and the active trail

A menu is displayed by a block. Each menu has a derived block plugin, system_menu_block:<menu name>, listed under the category "Menus" in Structure > Block layout > Place block. Its settings are the initial visibility level (level, 1 shows the top level), the number of levels to display (depth, unlimited by default) and expand all menu links (expand_all_items), plus a checkbox that adds a CSS class to the ancestors of the current page. Placing the same menu twice with different levels is the standard way to build a section navigation: one block with level: 1 and depth: 1 for the top bar, a second with level: 2 for the sidebar that shows the children of the current section.

# config/sync/block.block.meridian_footer.yml (excerpt)
langcode: en
status: true
dependencies:
  config:
    - system.menu.footer
  module:
    - system
  theme:
    - meridian
id: meridian_footer
theme: meridian
region: footer_bottom
weight: 0
provider: null
plugin: 'system_menu_block:footer'
settings:
  id: 'system_menu_block:footer'
  label: 'Footer menu'
  label_display: '0'
  provider: system
  level: 1
  depth: 1
  expand_all_items: false
visibility: {  }

The region name must exist in the theme’s *.info.yml; regions are covered in Theme Info and Regions and the block layout screens in Blocks and Block Layout. The block is cached with the cache tag config:system.menu.footer and, for menus that follow the active trail, a context that varies by the current route’s trail in that menu, so adding or reordering links invalidates the rendered menu everywhere without a cache rebuild.

Front page, 403 and 404

The front page is not a menu item but a setting: Configuration > System > Basic site settings (/admin/config/system/site-information) has a "Front page" field that takes a path (/home, /node/1 or an alias), exactly as the User Guide describes. The same screen sets the default 403 and 404 pages. All three are stored in system.site (page.front, page.403, page.404), and the form stores the system path even when you type an alias, so the setting survives later alias changes. The <front> route in a menu link always resolves to the configured value.

The block renders the menu through the menu.html.twig theme hook, with suggestions per menu (menu--main.html.twig, menu--footer.html.twig); override them in the theme as described in Template Suggestions and Preprocess. The variable items holds the links with title, url, below, in_active_trail, is_expanded and attributes.

{# meridian/templates/navigation/menu--footer.html.twig #}
{% import _self as menus %}
<ul{{ attributes.addClass('meridian-footer-menu') }}>
  {% for item in items %}
    <li{{ item.attributes.addClass('meridian-footer-menu__item') }}>
      {{ link(item.title, item.url) }}
    </li>
  {% endfor %}
</ul>

Source: Twig in Drupal

The {% import _self as menus %} line is a leftover from the Starterkit menu.html.twig recursion macro and can be removed from a flat menu. The link() function renders an anchor with the cacheability of the URL object; printing item.url as a string instead loses the access and cache metadata, which is the commonest cause of a link that is shown to the wrong audience.

Menus in code

A module offers links in MODULE.links.menu.yml. The keys are title (required), description, route_name (or url for an external link, which must be fully qualified), route_parameters, parent (the plugin ID of another link), weight, menu_name (the target menu; it defaults to tools), enabled (1 or 0) and options (as for Url::fromRoute()).

# meridian_collection/meridian_collection.links.menu.yml
meridian_collection.collection:
  title: 'Collection'
  description: 'Browse the museum collection.'
  route_name: view.collection.page_1
  menu_name: main
  weight: 40

meridian_collection.admin_collection:
  title: 'Collection objects'
  description: 'Manage collection objects and sync settings.'
  route_name: entity.collection_object.collection
  parent: system.admin_content
  weight: 10

meridian_collection.sync_settings:
  title: 'Collection sync'
  description: 'Configure the collections-management API sync.'
  route_name: meridian_collection.settings
  parent: system.admin_config_system
  weight: 20

Menu links are discovered into the menu_tree table; after adding or changing a links.menu.yml file, rebuild caches (drush cache:rebuild) so that the definition is rediscovered. A route name that does not exist raises a routing error when the link is rendered, not when it is discovered. The alter hook is hook_menu_links_discovered_alter(); there is no hook that adds a single link, and dynamic links belong in a deriver class, or in a call to addDefinition() on the plugin.manager.menu.link service, which requires a provider key. In Drupal 11.1 and later the alter hook is implemented as a method with the Hook attribute on a class in src/Hook, as covered in Hooks and Object-Oriented Hooks; books written for Drupal 10 show it as a function in the .module file, which still works through #[LegacyHook] compatibility but is no longer the recommended style.

<?php

declare(strict_types=1);

namespace Drupal\meridian_collection\Hook;

use Drupal\Core\Hook\Attribute\Hook;

/**
 * Menu hooks of the Meridian collection module.
 */
final class MenuHooks {

  /**
   * Moves the collection admin link below the "Content" section heading.
   *
   * @param array<string, array<string, mixed>> $links
   *   The discovered menu link definitions, keyed by plugin ID.
   */
  #[Hook('menu_links_discovered_alter')]
  public function alterLinks(array &$links): void {
    if (isset($links['meridian_collection.admin_collection'])) {
      $links['meridian_collection.admin_collection']['weight'] = -5;
    }
  }

}

A content link is a normal content entity, so an update hook, a post-update function or a migration can create them. The link field takes an internal: URI for a path, an entity:node/ID URI that survives alias changes, or an external URL.

<?php

use Drupal\menu_link_content\Entity\MenuLinkContent;

// In a post-update function: add the Visit pages under the "Visit" link.
$visit = MenuLinkContent::create([
  'title' => 'Opening hours',
  'link' => ['uri' => 'internal:/visit/opening-hours'],
  'menu_name' => 'main',
  'parent' => 'menu_link_content:' . $visit_uuid,
  'weight' => 5,
  'expanded' => FALSE,
]);
$visit->save();

Prefer entity:node/12 over internal:/exhibitions/dinosaur-hall for links to content: the entity: form is resolved to the current alias on every render, so a Pathauto regeneration never leaves a menu link pointing to a retired alias.

Reading and filtering a tree

The menu tree service (menu.link_tree, Drupal\Core\Menu\MenuLinkTreeInterface) loads, transforms and renders menu trees. The three steps are always the same: load a tree with MenuTreeParameters, run it through manipulators, build the render array.

<?php

declare(strict_types=1);

namespace Drupal\meridian_collection;

use Drupal\Core\Menu\MenuLinkTreeInterface;
use Drupal\Core\Menu\MenuTreeParameters;

/**
 * Builds a one-level render array of the footer menu.
 */
final class FooterLinks {

  public function __construct(
    private readonly MenuLinkTreeInterface $menuTree,
  ) {}

  /**
   * @return array<string, mixed>
   *   A render array carrying the menu's cache tags and contexts.
   */
  public function build(): array {
    $parameters = (new MenuTreeParameters())
      ->setMaxDepth(1)
      ->onlyEnabledLinks();
    $tree = $this->menuTree->load('footer', $parameters);
    $tree = $this->menuTree->transform($tree, [
      ['callable' => 'menu.default_tree_manipulators:checkNodeAccess'],
      ['callable' => 'menu.default_tree_manipulators:checkAccess'],
      ['callable' => 'menu.default_tree_manipulators:generateIndexAndSort'],
    ]);
    return $this->menuTree->build($tree);
  }

}

checkNodeAccess runs a single entity query for every node link in the tree and should run before checkAccess, which replaces inaccessible links with placeholder objects and empties their subtrees so that the result can be cached. flatten turns a tree into a single level. Omitting the access manipulator is a security defect: the menu would then list links to unpublished or restricted pages. build() adds the config:system.menu.<name> cache tag. The service is registered with autowiring aliases, so a class in src can request MenuLinkTreeInterface by type (see Services and Dependency Injection).

Menu hooks for the theme

A preprocess function or an OOP hook for the menu theme hook adds classes or attributes to every item; themes can implement hooks as classes from Drupal 11.3 and preprocess hooks from 11.2. Details are in Template Suggestions and Preprocess.

Path aliases

What an alias is

The User Guide defines the path as the part of the URL that identifies a function or a content item (node/7) and an alias as a readable replacement shown instead (/AboutUs). Aliases are stored as path_alias content entities, which Drupal has used since 8.8 instead of the old url_alias table. A path_alias has a path (the system path, which must start with a slash), an alias (which must start with a slash; whitespace and trailing slashes are trimmed on save), a langcode (default "Not specified", which applies to every language) and a status. It is revisionable, with the base table path_alias and the revision table path_alias_revision, and a constraint rejects a second alias that duplicates the same alias in the same language.

An alias is a second name, not a rename. The system path keeps working: /node/12 and /exhibitions/dinosaur-hall serve the same page unless the Redirect module’s route normalisation is on, which redirects the former to the latter. That duplication is the SEO reason for the Redirect module below.

Setting aliases by hand

With the Path module enabled, content, taxonomy term and user forms have a "URL alias" box, and the list of all aliases, filtered by alias, is at Configuration > Search and metadata > URL aliases (/admin/config/search/path). An editor needs create url aliases to set an alias on content; administer url aliases is needed for the list screen and for aliases that point at any path, not only at the entity being edited.

Rules the form enforces: the alias starts with a slash (a missing slash is a validation error), is unique per language, and the system path must exist and be accessible to the person who saves ("Either the path …​ is invalid or you do not have access to it"). Alias matching ignores letter case, so /About and /about are the same alias.

How a request is resolved

Two path processors do the work. On the way in, path_alias.path_processor (AliasPathProcessor, inbound priority 100) asks the alias manager for the system path that belongs to the requested alias, in the current language and then in "Not specified"; the router then sees /node/12. On the way out, the same service (outbound priority 300) asks for the alias of every system path that code turns into a URL, so Url::fromRoute('entity.node.canonical', ['node' => 12])->toString() returns the alias. The manager (path_alias.manager) caches results per language in cache.data, and the repository (path_alias.repository) is the only class that reads the table. The older path_alias.whitelist service, which kept the list of path prefixes that have aliases so that most requests skip the database, is deprecated since Drupal 11.1 and removed in 12.0; the prefix-list services replace it, so code that injected it must be updated.

flowchart LR V[Visitor requests /exhibitions/dinosaur-hall] --> IN[Inbound processor path_alias.path_processor] IN -->|alias manager lookup| SP[System path /node/12] SP --> R[Router and controller] R --> P[Rendered page] P --> L[Links built with Url::fromRoute] L --> OUT[Outbound processor] OUT -->|alias manager lookup| A[Alias /exhibitions/dinosaur-hall] DB[(path_alias table)] --- IN DB --- OUT

Because aliases are resolved before routing, an alias can shadow a real route. An administrator with administer url aliases who creates the alias /user/login for another page replaces the login form for every visitor. Treat the permission as sensitive, never give it to the Content Editor role, and review the alias list when a site behaves oddly at a well-known address. The same mechanism explains why a Pathauto pattern must never produce /admin, /user or other reserved first segments.

Aliases in code

<?php

use Drupal\path_alias\Entity\PathAlias;

// Create an alias for the "Plan your visit" page.
$alias = PathAlias::create([
  'path' => '/node/3',
  'alias' => '/visit',
  'langcode' => 'en',
]);
$alias->save();

// Resolve in both directions through the alias manager service.
$manager = \Drupal::service('path_alias.manager');
$system_path = $manager->getPathByAlias('/visit', 'en');   // '/node/3'
$public_path = $manager->getAliasByPath('/node/3', 'en');  // '/visit'

Source: PathAlias class

The \Drupal::service() call is acceptable in a script or a test; in a class, inject AliasManagerInterface. Saving and deleting an alias clears the alias manager cache for the affected paths. PathAlias::create() is also what a Migrate destination uses, so a CSV import of the collection can write aliases with the objects.

Aliases and languages

An alias belongs to one language or to "Not specified". Meridian’s Spanish site uses /es/exposiciones/sala-dinosaurios: the /es prefix is added by the language negotiation, not stored in the alias, and the alias /exposiciones/sala-dinosaurios has the langcode es. Pathauto creates one alias per translation when the pattern is translatable through the token [node:title] in the translation’s language. If the same alias exists in both languages the unique constraint is satisfied, because the uniqueness is per language, but a visitor who changes language without the prefix lands on the other language’s page, so prefix-based negotiation is the safer choice for aliases.

Automatic aliases with Pathauto

Pathauto is a contributed module; it requires Token and, since 8.x-1.11, no longer needs CTools. Install it with composer require drupal/pathauto and enable it with drush pm:install pathauto. Without Pathauto every alias is typed by an editor, which leads to inconsistent addresses (/Exhibitions/Dinosaurs, /dinosaur-hall-2) and forgotten aliases.

Patterns

Pathauto stores one pathauto_pattern configuration entity per pattern, edited at Configuration > Search and metadata > URL aliases > Patterns (/admin/config/search/path/patterns, permission administer pathauto). The pattern type is the entity (Content, Taxonomy term, User, and with other modules more), the pattern is a token string, and a pattern can be limited to content types and languages. A blank pattern for one type falls back to the default pattern; if the default is also blank, Pathauto does nothing for that type. Use "Browse available tokens" to find tokens; the usual ones are [node:title], [node:content-type], [node:created:custom:Y], [term:name] and [term:parents:join-path].

Meridian’s scheme is deliberately short and stable. A title-only address is easy to read but collides when two events share a title; adding the year to events solves that without making addresses long.

Content Pattern Example result

Exhibition

/exhibitions/[node:title]

/exhibitions/dinosaur-hall

Event

/events/[node:field_start_date:date:custom:Y]/[node:title]

/events/2027/night-at-the-museum

News

/news/[node:created:custom:Y]/[node:title]

/news/2026/new-planetarium

Basic page

/[node:title]

/visit

Taxonomy term (topic)

/topics/[term:name]

/topics/astronomy

The exported configuration of the exhibition pattern looks like this (create it in the UI and export it rather than hand-writing the UUIDs).

# config/sync/pathauto.pattern.exhibition.yml (excerpt)
langcode: en
status: true
dependencies:
  module:
    - node
id: exhibition
label: 'Exhibition'
type: 'canonical_entities:node'
pattern: '/exhibitions/[node:title]'
selection_criteria:
  4b3a6c0e-0000-4000-8000-000000000001:
    id: 'entity_bundle:node'
    negate: false
    uuid: 4b3a6c0e-0000-4000-8000-000000000001
    context_mapping:
      node: node
    bundles:
      exhibition: exhibition
selection_logic: and
weight: -5
relationships: {  }

Global settings

Configuration > Search and metadata > URL aliases > Settings (/admin/config/search/path/settings) holds the module-wide settings stored in pathauto.settings: the separator (a hyphen), the maximum alias length (150 in the shipped default) and component length (100), whether to transliterate to ASCII (on by default, essential for Spanish titles such as "Exposición"), the case rule (lower case), the list of words removed from titles (a, an, the, of and similar), the punctuation handling for each character, and the update action. The update action decides what happens when a title changes: do nothing and keep the old alias, create a new alias and leave the old one working, or overwrite the old alias. The shipped value is the last one, which breaks every inbound link and every search-engine entry to the old address. Whenever aliases are overwritten, install the Redirect module and let it create the redirect from the old address, or switch the site to keep old aliases.

Each content form also has a "Generate automatic URL alias" checkbox, on by default. Unchecking it lets an editor type a custom alias that Pathauto then leaves alone. A checkbox left on by mistake is the source of "my alias keeps changing" tickets.

Bulk generation and deletion

Aliases are generated when an entity is saved, so content that existed before the module was installed has none. The "Bulk generate" tab (/admin/config/search/path/update_bulk, needing administer pathauto and bulk update aliases) creates aliases for existing entities, and "Delete aliases" (/admin/config/search/path/delete_bulk, bulk delete aliases) removes automatic aliases. In a deployment the same operations are available in Drush.

# Create aliases for content that has none yet (action: create, update or all).
drush pathauto:aliases-generate create canonical_entities:node

# Regenerate every alias for all types; unavailable when old aliases are preserved.
drush pag all all

# Delete the automatically generated aliases of taxonomy terms.
drush pathauto:aliases-delete canonical_entities:taxonomy_term

# Delete every alias including manual ones. Destructive: take a database backup first.
drush pad all --purge

Bulk regeneration on a site with hundreds of thousands of entities runs through the batch API and can take long; run it from the command line, and do it before a launch, not after, because it changes addresses that search engines already know.

Common Pathauto pitfalls

  • Two items, one alias: Pathauto appends -0, -1 to make an alias unique, which is how /events/night-at-the-museum-0 appears. A year or an identifier in the pattern avoids it.

  • A long alias is truncated at the maximum length, possibly in the middle of a word; keep patterns short.

  • Tokens that change: a pattern containing a field that editors change (a category, a date) changes the address when the field changes, which with the default update action breaks links.

  • Pattern order: patterns have a weight and selection criteria; give a bundle-specific pattern a lower weight than the default pattern for the entity type and check the result in the bulk generate screen.

  • A pattern for a bundle that has the "Generate automatic URL alias" box disabled is ignored for that entity.

Redirects

The Redirect module

The Redirect module (permission administer redirects, settings need administer redirect settings) stores redirect entities and applies them early in request handling, before a "page not found" would be returned. The list and the add form are at Configuration > Search and metadata > URL redirects (/admin/config/search/redirect); a redirect has a source path (with optional query string), a language, a target (content, path or external URL) and a status code. Settings are at /admin/config/search/redirect/settings, and the shipped defaults, stored in redirect.settings, are these.

# config/sync/redirect.settings.yml
auto_redirect: true
default_status_code: 301
passthrough_querystring: true
warning: false
ignore_admin_path: false
access_check: false
route_normalizer_enabled: true

auto_redirect creates a redirect whenever the alias of an entity changes, which closes the gap left by Pathauto’s update action. route_normalizer_enabled redirects non-canonical paths, such as /node/12 for an aliased page and a path without a language prefix, to the canonical one; this is what the older Global Redirect module did, and Global Redirect is therefore no longer needed (older tutorials still recommend it). passthrough_querystring keeps the query string on the redirect target, and access_check makes the redirect depend on access to the target.

Use a permanent status (301) for content that moved for good. Use 302 or 307 only for temporary campaigns; search engines do not transfer ranking through them. The Redirect 404 submodule logs aggregated "not found" requests so an administrator can turn the most frequent into redirects, and can keep them out of the default log.

Redirects for a changed address

Meridian renames its "Exhibitions" section to "What’s on". The Pathauto pattern changes and the bulk update rewrites the aliases, but the old address /exhibitions/dinosaur-hall is in guidebooks and search results. With auto_redirect on, each regenerated alias leaves a redirect behind. For addresses that were never aliases (an older site’s /old/dinosaurs.html) a redirect is created by hand, in a migration, or in code.

<?php

use Drupal\redirect\Entity\Redirect;

$redirect = Redirect::create();
$redirect->setSource('old/dinosaurs.html');
$redirect->setRedirect('/whats-on/dinosaur-hall');
$redirect->setLanguage('en');
$redirect->setStatusCode(301);
$redirect->save();

A source path is stored without the leading slash; a target is a path or an internal: or entity: URI. For thousands of redirects from a former site, create the entities in a Migrate pipeline (see Migrate API) instead of a loop in a script.

Redirect pitfalls

  • Loops: an alias that is also a redirect source, or two redirects that point at each other, give "too many redirects". Check any new redirect by requesting the source address before saving it.

  • Redirects hide content: a redirect is checked before the router, so a redirect from /visit stops the real page at that alias from ever being served. Delete the redirect when the address is reused.

  • Redirect chains (A to B to C) slow visitors; update older redirects to the final target when a path moves twice.

  • The performance cost of redirect lookups on every request is small but real; delete redirects that no longer receive traffic, using the optional redirect counter of Redirect Metrics.

  • Query strings in the source are matched literally, so campaign parameters need passthrough_querystring.

For WordPress and Webflow readers: the equivalent screens are described in Rewrite API and Permalinks and Redirects and Site Migration.

Clean URLs and web-server rewriting

Drupal 7 had a setting that switched "clean URLs" off, producing ?q=node/12. Since Drupal 8 clean URLs are always on and there is no setting; the requirement moved to the web server, which must send every request that does not match a real file or directory to index.php. When this is missing the front page works but every other address returns a web-server 404, and the installer cannot proceed.

Apache

Drupal ships a customised .htaccess file in the project root of the web directory, and this file contains the rewrite. In outline it turns the engine on, optionally forces or removes www. (both commented out), lets you set RewriteBase for a subdirectory install (commented out), and sends everything that is not an existing file or directory, other than the favicon, to the front controller. It also denies access to files that must not be served (.module, .inc, .yml, backups and other sensitive extensions) and to PHP files outside /core except the front controller. The rewrite lines are these.

RewriteEngine on

# Pass every non-file, non-directory request to the front controller.
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteCond %{REQUEST_URI} !=/favicon.ico
RewriteRule ^ index.php [L]

The virtual host must allow overrides (AllowOverride All) and load mod_rewrite; otherwise the file is ignored, which on a server that ignores .htaccess also removes the protection of private files and the .yml blocks. If the web root is not the project’s web directory, the file is not applied at all. Updating Drupal sometimes changes .htaccess (the 11.4.11 and 10.6.20 releases replaced the assert.active setting with zend.assertions); Composer does not overwrite your customised copy, so compare it with the one in the scaffold after each core update.

nginx

Drupal ships no nginx configuration; a request to add an official sample is still open in the core issue queue. The central rule is the equivalent of the Apache lines: try the file, then hand the request to index.php together with the query string. Other rules, for static files and private directories, belong to a complete server block.

server {
    listen 80;
    server_name meridian.example;
    root /var/www/meridian/web;
    index index.php;

    location / {
        try_files $uri /index.php?$query_string;
    }

    # Pass PHP to PHP-FPM, only for index.php and the core scripts.
    location ~ '\.php$|^/update.php' {
        fastcgi_split_path_info ^(.+?\.php)(|/.*)$;
        try_files $fastcgi_script_name =404;
        include fastcgi_params;
        fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
        fastcgi_param PATH_INFO $fastcgi_path_info;
        fastcgi_pass unix:/run/php/php8.3-fpm.sock;
    }

    # Never serve private files or the configuration directory.
    location ~ ^/sites/.*/private/ { return 403; }
    location ~ \.(yml|inc|module|install|theme|engine|sh)$ { deny all; }
}

Local DDEV projects generate this configuration for you (see Installation with Composer and DDEV). Hosting requirements, including the end of IIS support in 11.0, are in Requirements and Hosting. Proxies and CDNs in front of the site must also pass the HTMX request headers (HX-Request, HX-Redirect) since 11.3, and must not rewrite the path themselves, or aliases and redirects will not match.

Security and operations

  • Aliases are not access control. A page behind an obscure alias is still reachable by its system path, and menu links hide only from people without access to the target. Protect content with permissions (see Users, Roles and Permissions).

  • Guard administer url aliases, administer redirects and administer menu. All three change what visitors see at an address, and an open redirect to an arbitrary external URL helps phishing; keep external targets under review.

  • Do not put user-controlled text in a menu title unfiltered. Titles are escaped by Twig; |raw on a menu title reintroduces cross-site scripting.

  • Treat .htaccess and server blocks as code: keep them in version control and check them after core updates (see Security Hardening).

  • Cache invalidation: rendered menus carry the config:system.menu.<name> tag; custom code that edits links without saving entities must invalidate that tag, or editors will report that a new link "does not show".

Common errors

Symptom Cause and fix

Front page works, every other page gives a web-server 404

Rewrite missing: mod_rewrite or AllowOverride on Apache, or try_files on nginx.

New menu link not visible

The target is unpublished or the visitor lacks access; the block region is missing; the menu is not available in the block layout; the link is disabled.

A links.menu.yml link missing

Cache not rebuilt, wrong menu_name (the default is tools), or the route name does not exist.

"The alias is already in use in this language"

A second alias with the same text and language; delete the old one or pick another.

/node/12 and the alias both work

Route normalisation off or Redirect not installed; install Redirect and enable normalisation.

Alias changes every time the title is edited

Update action overwrites; disable "Generate automatic URL alias" on the form or change the Pathauto update action.

Links in a menu point to old addresses

Links use internal: paths; recreate them as entity:node/ID links.

Redirect does not apply

Source typed with a leading slash or wrong language; a cached 301 in the browser; the CDN caches the old response.

Version notes and book corrections

  • Drupal 8.8 replaced the url_alias table by the path_alias entity; sources that call \Drupal::service('path.alias_storage') describe the former implementation.

  • The path_alias.whitelist service is deprecated in 11.1 and removed in 12.0.

  • Plugins now use PHP attributes: the menu block is declared with #[Block(...)] in core since 10.2; books that show an @Block annotation for a menu-related block show the older style.

  • Drupal 10 Development Cookbook (2023) shows menu-related hooks as functions in the .module file and Drupal 10 as the target; since 11.1 the recommended form is a method with the Hook attribute on a class in src/Hook.

  • Drupal 10 Masterclass (2023) presents the Standard profile with the Article and Page content types and Bartik-era region names in its menu and block examples; the Standard profile no longer creates those types (11.4), Bartik is gone, and Olivero leaves core in Drupal 12 together with Claro and Stable 9.

  • Older books and tutorials say to install Global Redirect for canonical redirects; the Redirect module’s route normaliser now does that.

  • Older books show a "Clean URLs" setting under Configuration; it was removed in Drupal 8.

  • The Drupal 12 release (planned for the week of 2026-12-07) moves the Shortcut and Toolbar modules to contrib; the menus, Menu UI, Menu Link Content and Path modules stay in core, but a site that relies on the Toolbar must install its contributed version before upgrading and must not uninstall it first.