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 ( |
Core, always on |
The |
Menu UI ( |
Core, optional |
The screens under Structure > Menus ( |
Menu Link Content ( |
Core, optional |
The |
Path ( |
Core, optional |
The "URL alias" widget on content forms and the URL aliases screen ( |
Path Alias ( |
Core, required |
The |
Pathauto ( |
Contributed, needs Token |
Pattern-based alias generation for content, terms and users. |
Redirect ( |
Contributed |
The |
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 ( |
Site builders |
The links that visitors use to move around; new content menu links go here by default. |
Footer ( |
Site builders |
Legal and contact links for the footer region. |
Tools ( |
Modules |
Links modules offer to visitors; also the default target of a module link that names no |
User account menu ( |
Modules |
My account, Log in and Log out. |
Administration ( |
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.
Creating a menu and its links
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
MenuLinkContententities (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: { }
Source: Drupal User Guide: Concept: Menu
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.
Menu links in templates
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
Static links in a module
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;
}
}
}
Creating content links programmatically
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();
Source: MenuLinkContent class
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);
}
}
Source: MenuLinkTree class
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.
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 |
|
|
Event |
|
|
News |
|
|
Basic page |
|
|
Taxonomy term (topic) |
|
|
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: { }
Source: Pathauto project page
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
Source: Pathauto source repository
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,-1to make an alias unique, which is how/events/night-at-the-museum-0appears. 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
Source: Redirect project page
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();
Source: Redirect project page
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
/visitstops the real page at that alias from ever being served. Delete the redirect when the address is reused. -
Redirect chains (
AtoBtoC) 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]
Source: Drupal core: .htaccess
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 redirectsandadminister 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;
|rawon a menu title reintroduces cross-site scripting. -
Treat
.htaccessand 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: |
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 |
Cache not rebuilt, wrong |
"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. |
|
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 |
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_aliastable by thepath_aliasentity; sources that call\Drupal::service('path.alias_storage')describe the former implementation. -
The
path_alias.whitelistservice 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@Blockannotation for a menu-related block show the older style. -
Drupal 10 Development Cookbook (2023) shows menu-related hooks as functions in the
.modulefile and Drupal 10 as the target; since 11.1 the recommended form is a method with theHookattribute on a class insrc/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.