Créer des breadcrumbs personnalisés pour vos Controllers/Forms dans Drupal
Auteur(s) de l'article
En général, lorsque l’on travaille avec Drupal, le breadcrumb suit la logique de l’URL (d’autres stratégies existent mais nous allons nous limiter à celle-ci pour la compréhension de cet article). De fait, out-of-the-box tout fonctionne parfaitement : vos pages et votre menu permettent de faire une hiérarchie et votre fil d’Ariane reflète cette dernière.

Mais lorsque l’on doit développer un custom
Controller ou Form avec une route unique, là ça se corse ! En effet, votre URL ne va pas suivre la hiérarchie de votre menu et votre breadcrumb va en être impacté.Prenons un exemple très simple. Mon projet possède des offres d’emplois, et j’aimerais avoir un formulaire dédié de postulation pour chaque offre. Je vais donc développer un custom
Form avec un path /job/{job_offer}/apply.Mon
breadcrumb pour ces pages va alors ressembler peu ou prou àAccueil > PostulerProblème : Nous on aimerait bien que notre breadcrumb propose l’ensemble du parcours utilisateur
Accueil > Nos offres d'emplois > Assistante Médicale > PostulerDeux solutions s’offrent alors à vous :
- Générer un alias d’URL pour votre route
/job/{job_offer}/applyà chaque création/modification/suppression d'une Node de type Job. - Générer un custom
BreadcrumbBuilderpour votre Route.
Je vais vous montrer comment implémenter la seconde solution, bien plus élégante et maintenable ! Dans mon dernier projet pour un centre médical, j’ai eu exactement ce défi et voici comment je l’ai résolu.
Le système de breadcrumb: Les fondamentaux
Drupal utilise un système de services pour générer ses
Breadcrumb.Le principe est simple : Drupal parcourt tous les services taggés
breadcrumb_builder, les trie par priorité, et demande à chacun s'il "s'applique" à la page courante via la méthode applies(). Le premier qui répond "oui" construit le breadcrumb avec sa méthode build().C’est ce qu’on appelle une Chaîne de Responsabilité. J’ai écrit un article si vous voulez digresser à ce sujet.
Déclaration du service : La base
Commençons par déclarer notre service dans
my_module.services.ymlservices:
# Breadcrumb
my_module.breadcrumb.apply_form:
class: Drupal\my_module\Breadcrumb\ApplyFormBreadcrumbBuilder
arguments:
- '@entity_type.manager'
- '@plugin.manager.template_whisperer'
- '@template_whisperer.suggestion.usage'
tags:
- { name: breadcrumb_builder, priority: 1010 }Point crucial : La priorité
1010 ! C'est elle qui détermine l'ordre d'exécution. Les builders par défaut de Drupal ont généralement des priorités inférieures (souvent autour de 0 à 100). En mettant 1010, on s'assure que notre builder sera interrogé en premier.⚠️ Il se peut qu’à l’avenir et selon les modifications du Core de Drupal, ce chiffre doive être adapté.
L’implémentation
Voici notre classe complète avec tous les détails
<?php
namespace Drupal\my_module\Breadcrumb;
use Drupal\Core\Breadcrumb\Breadcrumb;
use Drupal\Core\Breadcrumb\BreadcrumbBuilderInterface;
use Drupal\Core\Cache\CacheableMetadata;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\Link;
use Drupal\Core\Routing\RouteMatchInterface;
use Drupal\Core\StringTranslation\StringTranslationTrait;
use Drupal\Core\Url;
use Drupal\node\NodeInterface;
use Drupal\template_whisperer\Entity\TemplateWhispererSuggestionEntity;
use Drupal\template_whisperer\TemplateWhispererManager;
use Drupal\template_whisperer\TemplateWhispererSuggestionUsage;
/**
* Provides custom breadcrumb on Apply Form.
*/
final class ApplyFormBreadcrumbBuilder implements BreadcrumbBuilderInterface {
use StringTranslationTrait;
/**
* The node storage service.
*
* @var \Drupal\Core\Entity\EntityStorageInterface
*/
private $nodeStorage;
/**
* Constructs a new ApplyFormBreadcrumbBuilder object.
*/
public function __construct(
EntityTypeManagerInterface $entity_type_manager,
protected TemplateWhispererManager $templateWhisperer,
protected TemplateWhispererSuggestionUsage $templateWhispererUsage,
) {
$this->nodeStorage = $entity_type_manager->getStorage('node');
}
/**
* {@inheritdoc}
*/
#[\Override]
public function applies(RouteMatchInterface $route_match, ?CacheableMetadata $cacheable_metadata = NULL): bool {
return $route_match->getRouteName() === 'my_module.apply_form';
}
/**
* {@inheritdoc}
*/
#[\Override]
public function build(RouteMatchInterface $route_match): Breadcrumb {
$breadcrumb = new Breadcrumb();
/** @var \Drupal\node\NodeInterface $job */
$job = $route_match->getParameter('job');
if (!$job instanceof NodeInterface || $job->bundle() !== 'job') {
return $breadcrumb;
}
$suggestion = $this->templateWhisperer->getOneBySuggestion('job_offers_collection');
if (!$suggestion instanceof TemplateWhispererSuggestionEntity) {
return $breadcrumb;
}
$pages = $this->templateWhispererUsage->listUsage($suggestion);
$page = reset($pages);
if ($page === NULL || $page === FALSE) {
return $breadcrumb;
}
// Listing of Jobs.
/** @var \Drupal\node\NodeInterface $jobs */
$jobs = $this->nodeStorage->load($page->id);
$breadcrumb->addLink(Link::createFromRoute((string) $jobs->getTitle(), 'entity.node.canonical', ['node' => $jobs->id()]));
$breadcrumb->addCacheableDependency($jobs);
// Current Job page.
$breadcrumb->addCacheableDependency($job);
$breadcrumb->addLink(Link::createFromRoute((string) $job->getTitle(), 'entity.node.canonical', ['node' => $job->id()]));
// Last link in the Breadcrumb.
$breadcrumb->addLink(Link::fromTextAndUrl((string) $route_match->getRouteObject()?->getDefault('_title'), Url::fromRoute('<current>')));
// This breadcrumb builder is based on a route parameter, and hence it
// depends on the 'route' cache context.
$breadcrumb->addCacheContexts(['route']);
// Return object of type breadcrumb.
return $breadcrumb;
}
}La méthode applies()
public function applies(RouteMatchInterface $route_match, ?CacheableMetadata $cacheable_metadata = NULL): bool {
return $route_match->getRouteName() === 'my_module.apply_form';
}Cette méthode est votre “portier”. Elle détermine si ce builder doit s’activer. Ici, on cible uniquement la route
my_module.apply_form. Simple, efficace, précis.Template Whisperer : Fini le hardcoding !
Au lieu de hardcoder l’ID de la page “Offres d’emploi”, j’utilise Template Whisperer. Cette approche me permet de cibler précisément cette page (qui est le parent de nos offres d’emplois) sans avoir à hardcoder l’ID de cette Basic Page et ainsi laisser libre champ au client de modifier la page, son emplacement, etc.
$suggestion = $this->templateWhisperer->getOneBySuggestion('job_offers_collection');
$pages = $this->templateWhispererUsage->listUsage($suggestion);
$page = reset($pages);Construction du breadcrumb
- Page de listing des emplois : Récupérée via Template Whisperer.
- Page de l’emploi actuel : Extraite des paramètres de route.
- Page courante : “Postuler” avec le titre de la route.
// Listing of Jobs.
$breadcrumb->addLink(Link::createFromRoute((string) $jobs->getTitle(), 'entity.node.canonical', ['node' => $jobs->id()]));
// Current Job to apply for.
$breadcrumb->addLink(Link::createFromRoute((string) $job->getTitle(), 'entity.node.canonical', ['node' => $job->id()]));
// Last link (current page).
$breadcrumb->addLink(Link::fromTextAndUrl((string) $route_match->getRouteObject()?->getDefault('_title'), Url::fromRoute('<current>')));Le cache
$breadcrumb->addCacheableDependency($job);
$breadcrumb->addCacheContexts(['route']);Crucial ! Sans ces lignes, votre breadcrumb pourrait être mis en cache de manière incorrecte.
addCacheableDependency() invalide le cache quand la Job Offer change, et addCacheContexts(['route']) assure que chaque route (/job/{job_offer}/apply) a son propre cache. Car nous sommes ici sur un breadcrumb lié à une route qui doit varier pour chaque offre d'emploi.Conclusion

Et voilà ! Nous avons créé un breadcrumb personnalisé qui suit parfaitement la logique métier de notre application plutôt que la structure technique des URLs. Cette approche avec un
BreadcrumbBuilder personnalisé offre une solution élégante et maintenable pour tous vos besoins de navigation contextuelle.Cette approche peut facilement s’adapter à d’autres cas d’usage : formulaires multi-étapes, pages de confirmation, interfaces d’administration personnalisées… Les possibilités sont infinies !
❤️ Coeur sur vos claviers.
Sources
Flocon de toile (Mars 2019). Customizing the breadcrumb trail with Drupal 8.
https://www.flocondetoile.fr/blog/customizing-breadcrumb-trail-drupal-8
https://www.flocondetoile.fr/blog/customizing-breadcrumb-trail-drupal-8
Drupal Community (Octobre 2021). Custom Module using Breadcrumbs as a Service.
https://www.drupal.org/forum/support/module-development-and-code-questions/2021-10-14/custom-module-using-breadcrumbs-as-a-service-main-methode-applies-is-not-getting-implemented
https://www.drupal.org/forum/support/module-development-and-code-questions/2021-10-14/custom-module-using-breadcrumbs-as-a-service-main-methode-applies-is-not-getting-implemented
Azz-eddine Berramou (Novembre 2019). Drupal 8 Two ways to customise Breadcrumb.
https://www.berramou.com/blog/drupal-8-two-ways-customise-breadcrumb
https://www.berramou.com/blog/drupal-8-two-ways-customise-breadcrumb