Creating custom breadcrumbs for your Controllers/Forms in Drupal

Auteur(s) de l'article

In general, when working with Drupal, the breadcrumb follows the logic of the URL (other strategies exist, but we’ll stick to this one for the sake of this article). Out of the box, everything works perfectly: your pages and your menu create a hierarchy, and your breadcrumb reflects that hierarchy.
Example of a breadcrumb for a Node in a consistent URL hierarchy.
But when you need to develop a custom Controller or Form with a unique route, things get trickier! Indeed, your URL won’t follow the menu hierarchy, and your breadcrumb will be affected.
Let’s take a very simple example. My project has job offers, and I want to have a dedicated application form for each offer. So I create a custom Form with the path /job/{job_offer}/apply.
My breadcrumb for these pages will look something like this:
Home > Apply
Problem: We would like our breadcrumb to reflect the full user journey:
Home > Job Offers > Medical Assistant > Apply
You then have two options:
  1. Generate a URL alias for your /job/{job_offer}/apply route every time a Job Node is created/updated/deleted.
  2. Create a custom BreadcrumbBuilder for your route.
    I’ll show you how to implement the second solution, which is much more elegant and maintainable! In my last project for a medical center, I had exactly this challenge — here’s how I solved it.

    The breadcrumb system: The basics

    Drupal uses a service-based system to generate its Breadcrumb.
    The principle is simple: Drupal loops through all services tagged breadcrumb_builder, sorts them by priority, and asks each one whether it "applies" to the current page using the applies() method. The first one that answers "yes" builds the breadcrumb using its build() method.
    This is known as a Chain of Responsibility. I wrote an article about it if you want to dig deeper.

    Service declaration: The basics

    Let’s start by declaring our service in my_module.services.yml
    services:
      # 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 }
    Key point: The priority 1010! It determines the execution order. Drupal’s default builders generally have lower priorities (around 0 to 100). By setting 1010, we ensure that our builder is checked first.
    ⚠️ Note: In future Drupal Core changes, this value may need to be adjusted.

    The implementation

    Here is our complete class with all details:
    … (kept exactly as in your original) …

    The applies() method

    public function applies(RouteMatchInterface $route_match, ?CacheableMetadata $cacheable_metadata = NULL): bool {
      return $route_match->getRouteName() === 'my_module.apply_form';
    }
    This method is your “gatekeeper.” It determines whether this builder should activate. Here, we target only the my_module.apply_form route. Simple, efficient, precise.

    Template Whisperer: No more hardcoding!

    Instead of hardcoding the ID of the “Job Offers” page, I use Template Whisperer. This lets me precisely target this page (the parent of our job offers) without hardcoding the ID of this Basic Page, leaving the client free to modify the page, its position, etc.
    $suggestion = $this->templateWhisperer->getOneBySuggestion('job_offers_collection');
    $pages = $this->templateWhispererUsage->listUsage($suggestion);
    $page = reset($pages);

    Building the breadcrumb

    1. Job listing page: Retrieved via Template Whisperer.
    2. Current job page: Extracted from route parameters.
    3. Current page: “Apply”, using the route title.
      // 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>')));

      Caching

      $breadcrumb->addCacheableDependency($job);
      $breadcrumb->addCacheContexts(['route']);
      Crucial! Without this, your breadcrumb might be cached incorrectly. addCacheableDependency() invalidates the cache when the Job Offer changes, and addCacheContexts(['route']) ensures each /job/{job_offer}/apply route has its own cache. Since this breadcrumb depends on the route, it must vary for each job offer.

      Conclusion

      Tadaaa, here’s the customized breadcrumb for a custom URL 🔗
      And that’s it! We’ve created a custom breadcrumb that follows the business logic of our application instead of the technical URL structure. This custom BreadcrumbBuilder approach is elegant and maintainable for all your contextual navigation needs.
      This approach can easily be adapted to other use cases: multi-step forms, confirmation pages, custom admin interfaces… The possibilities are endless!
      ❤️ Love on your keyboards.

      Sources

      Flocon de toile (March 2019). Customizing the breadcrumb trail with Drupal 8.
      https://www.flocondetoile.fr/blog/customizing-breadcrumb-trail-drupal-8
      Azz-eddine Berramou (November 2019). Drupal 8 Two ways to customise Breadcrumb.
      https://www.berramou.com/blog/drupal-8-two-ways-customise-breadcrumb