Implementing breadcrumb navigation in Drupal with custom logic
Breadcrumb navigation gives visitors a compact trail from the site root to the current page. It is especially useful in Drupal when content lives under editorial categories that do not always match its URL. A dependable implementation must also work for taxonomy pages, views, landing pages and pages reached through aliases. Rather than force one hierarchy onto every route, build a trail from Drupal’s route and entity data.
Start by deciding what each level means. For a news article, the trail might be Home > Topics > Drupal Development > Article title. For a listing, it may be Home > Resources. The current title is the final item and normally is not a link. Keep labels short, preserve the site’s real information architecture, and never derive the hierarchy from the browser path alone. Drupal’s path aliases can change, disappear or point somewhere else, so they should control destinations, not define meaning.
This implementation uses a small custom Drupal module and a service that returns structured breadcrumb data. A preprocess hook supplies that data, and a Twig template renders it as an ordered list with the current page identified. The approach keeps presentation separate from route logic, making it practical for a content team to maintain. It also leaves room for Australian sites serving multiple cities, audiences and accessibility obligations.
| Method | Best fit | Benefit | Main limitation |
|---|---|---|---|
| Existing breadcrumb module | Standard routes and simple sites | Quick to install and familiar | Limited control over editorial rules |
| Taxonomy parent terms | Node-based content with categories | Mirrors the information architecture | Requires suitable fields and configuration |
| URL alias segments | Sites with permanent, simple paths | No additional configuration | Breaks down when aliases change |
| Custom breadcrumb service | Mixed routes and bespoke hierarchies | Precise control over every level | Requires code and ongoing maintenance |
Choosing a reliable hierarchy
Choose the hierarchy from editorial relationships, not from whatever happens to sit between two slashes. For node content, a taxonomy field is usually the best source: its selected term can supply the section, and each term’s parent relationship can supply higher levels. The final level is the entity title. This keeps authors in control even when an editor changes an alias.
Taxonomy needs rules when a content type has several reference fields or multiple terms. Use a dedicated single-value field for the primary section when the hierarchy must be deterministic; otherwise select the first term in a defined field order and document that choice. Do not present every tagged term as a breadcrumb level, because that creates sibling paths where visitors expect one clear route. Terms with parents remain valuable: a child such as “Drupal development” can sit below “Development”.
Non-entity routes need a separate mapping. A Drupal View exposed at view.articles has no _entity parameter, so the service can read a configured label and treat that page as the current level. Keep configuration in YAML rather than in the PHP class, and record the route name rather than an alias. This preserves a sensible trail if somebody later renames /resources to /learning.
Building the breadcrumb service
Create a module named custom_breadcrumb, enable it, and place configuration in config/install/custom_breadcrumb.settings.yml. The following example maps content types to taxonomy fields and View routes to display labels. Replace the example route names with routes that exist in your installation; a missing route should fail during testing, not on a live page.
content_types:
article:
taxonomy_field: field_topic
fallback: Articles
route: view.articles
service:
taxonomy_field: field_service_area
fallback: Services
route: view.services
views:
'view.articles': Articles
'view.services': Services
The service accepts the current route match, inspects _entity, and produces both trail items and cache metadata. This keeps procedural preprocessing small and gives you a reusable API for JSON responses, emails or other renderers later. The examples follow Drupal 10 and 11 conventions.
<?php
namespace Drupal\custom_breadcrumb;
use Drupal\Core\Cache\CacheableMetadata;
use Drupal\Core\Config\ConfigFactoryInterface;
use Drupal\Core\Entity\EntityInterface;
use Drupal\Core\Entity\FieldableEntityInterface;
use Drupal\Core\Routing\RouteMatchInterface;
use Drupal\Core\StringTranslation\StringTranslationInterface;
use Drupal\Core\Url;
use Drupal\taxonomy\TermInterface;
class BreadcrumbTrail {
public function __construct(
protected ConfigFactoryInterface $config,
protected StringTranslationInterface $translation,
) {}
public function build(RouteMatchInterface $route_match): array {
$config = $this->config->get('custom_breadcrumb.settings');
$settings = $config->get();
$metadata = new CacheableMetadata();
$metadata->addCacheContexts(['url.path']);
$metadata->addCacheTags($config->getCacheTags());
$trail = [[
'title' => $this->translation->translate('Home'),
'href' => Url::fromRoute('<front>')->toString(),
]];
$entity = $route_match->getParameter('_entity');
if ($entity instanceof TermInterface) {
foreach ($this->termTrail($entity) as $term) {
$trail[] = [
'title' => $term->label(),
'href' => $term->toUrl('canonical')->toString(),
];
$metadata->addCacheTags($term->getCacheTags());
}
$trail[count($trail) - 1]['href'] = NULL;
return ['items' => $trail, 'metadata' => $metadata];
}
if ($entity instanceof EntityInterface) {
$map = $settings['content_types'][$entity->getEntityTypeId()] ?? [];
$field = $map['taxonomy_field'] ?? '';
$term = NULL;
if ($field && $entity instanceof FieldableEntityInterface
&& $entity->hasField($field)) {
foreach ($entity->get($field) as $item) {
if ($item->entity instanceof TermInterface) {
$term = $item->entity;
break;
}
}
}
if ($term) {
foreach ($this->termTrail($term) as $ancestor) {
$trail[] = [
'title' => $ancestor->label(),
'href' => $ancestor->toUrl('canonical')->toString(),
];
$metadata->addCacheTags($ancestor->getCacheTags());
}
}
elseif (!empty($map['fallback'])) {
$trail[] = [
'title' => $this->translation->translate($map['fallback']),
'href' => !empty($map['route'])
? Url::fromRoute($map['route'])->toString()
: NULL,
];
}
$trail[] = [
'title' => (string) $entity->label(),
'href' => NULL,
];
$metadata->addCacheTags($entity->getCacheTags());
return ['items' => $trail, 'metadata' => $metadata];
}
$route_name = $route_match->getRouteName();
if (isset($settings['views'][$route_name])) {
$trail[] = [
'title' => $this->translation->translate(
$settings['views'][$route_name]
),
'href' => NULL,
];
}
return ['items' => $trail, 'metadata' => $metadata];
}
protected function termTrail(TermInterface $term): array {
$terms = [];
while ($term) {
array_unshift($terms, $term);
$term = $term->getParent();
}
return $terms;
}
}
Each term is walked from its selected ancestor to its child, then linked through the canonical route. The node title closes the trail without an href. If no taxonomy term exists, the configured fallback keeps the node under a broad section instead of producing an isolated item beside Home. The metadata includes the url.path context and entity cache tags, so title and taxonomy changes can invalidate affected output.
Connecting breadcrumbs to the theme
Register the class in custom_breadcrumb.services.yml, then add a preprocess hook. Drupal injects both constructor dependencies through the service container.
services:
custom_breadcrumb.breadcrumb_trail:
class: Drupal\custom_breadcrumb\BreadcrumbTrail
arguments:
- '@config.factory'
- '@string_translation'
The following preprocess hook replaces the active theme’s breadcrumb array. It assumes the theme uses Drupal’s breadcrumb.html.twig; if the template has another base name, use the matching preprocess hook and keep the same variable contract. The metadata is attached to the render array so Drupal can cache and purge it correctly.
use Drupal\Core\Render\BubbleableMetadata;
function custom_breadcrumb_preprocess_breadcrumb(array &$variables): void {
$result = \Drupal::service('custom_breadcrumb.breadcrumb_trail')
->build(\Drupal::routeMatch());
$variables['breadcrumb'] = $result['items'];
BubbleableMetadata::createFromObjects([$result['metadata']])
->applyTo($variables);
}
The markup uses an ordered list, which communicates hierarchy to assistive technology. Only linked levels contain anchors, while the final item receives aria-current="page". If a visible slash is added, mark it aria-hidden="true" so it is not announced. The aria-label identifies the navigation landmark without adding another visible heading.
<nav class="breadcrumb" aria-label="{{ 'Breadcrumb'|t }}">
<ol class="breadcrumb__list">
{% for item in breadcrumb %}
<li class="breadcrumb__item">
{% if item.href and not loop.last %}
<a href="{{ item.href }}">{{ item.title }}</a>
{% else %}
<span{% if loop.last %} aria-current="page"{% endif %}>
{{ item.title }}
</span>
{% endif %}
</li>
{% endfor %}
</ol>
</nav>
Handling aliases, views, access and caching
Aliases are destinations, not hierarchy. Canonical URLs let Drupal resolve the correct route, while the trail remains anchored to taxonomy parents. If an alias points to a restricted node, check route access before exposing that URL. Breadcrumbs should not disclose hidden content types, unpublished records, customer identifiers or search filters merely because their names appear in the route.
Cache behaviour depends on what changes. The url.path context separates trails for different URLs, while node and taxonomy cache tags purge a page when a title, parent or label changes. An alias update also changes routing, so include alias changes in the deployment test and rebuild the route cache if the site uses cached custom routes. Avoid caching a fully rendered trail in application code, because that bypasses Drupal’s invalidation system.
Handle useful exceptions deliberately. A node with two topic terms needs a deterministic primary term; a taxonomy term page needs its ancestors followed by itself; and an unmatched 404 route needs only Home or no breadcrumb at all. For multilingual sites, generate links in the active language and verify that translated terms do not create duplicate levels. Never expose internal route names such as entity.node.canonical to visitors when a friendly label is available.
Testing and maintaining the implementation
Test the behaviour against a realistic matrix: content with no term, one term and several nested terms; a node using the fallback; listing, search and 404 routes; unpublished and access-restricted pages; long Australian English labels; and aliases containing dates in DD/MM/YYYY format. Check keyboard focus order, current-page semantics, mobile widths and the absence of horizontal overflow. A trail that works on a wide design monitor but wraps badly on a commuter’s phone is not finished.
Australian teams often publish across cities: content may be authored in Sydney for a Melbourne office, while a council in Canberra and a tourism operator in Cairns use the same platform. This does not mean every location should appear in the trail; taxonomy should expose only the hierarchy that helps a visitor. Compact labels are valuable because checking a site on a phone during a train or tram journey is a normal habit, and mobile coverage can weaken in tunnels or outer suburbs.
The local market mix also shapes the content model. Australian briefs commonly span council, education, tourism and ecommerce work; an ecommerce trail may sit above an AUD-priced category while filters and the 10% GST remain page details rather than breadcrumb levels. Under the Privacy Act 1988 and the Australian Privacy Principles, unpublished or personal information should not be inferable from a path. Australian Government accessibility work commonly targets WCAG 2.1 AA, with WCAG 2.2 AA providing a stronger baseline for new components. Deploy the module in staging, monitor cache invalidation after taxonomy edits, and keep route-to-label mappings in the project’s configuration documentation.