Drupal 8 How-to: Switch theme based on the page's active menu

UPDATE: I've finally taken the time to generalize this approach (read: make it configurable via the Drupal UI) and posted it as a sandbox project on d.o: Menu-driven theme.

Part of what makes Drupal 8 awesome is the modern PHP architecture, which makes it possible to build cool functionality changes with very little code required. Of course, it takes time to figure out WHERE that code needs to go, beyond just "in a custom module".

As part of my first Drupal 8 project, I needed to be able to switch the theme between the main site, and three specific sub-sections of the site. Luckily, each of these sections were to have their own menu, and only pages that appeared in that menu should have their theme switched.

I was able to accomplish this in about four hours, which I'm pretty proud of.

Investigation

Here are the steps I remember taking to solve this issue. If you just want the code, go ahead and skip this section -- but come back and read this part when you have more time!

Step 1: Look at contrib modules

I used ThemeKey for a Drupal 6 project back in my agency days, so it was one of the first things I looked at. At the time I was building out this functionality (and at the time of this writing), that module hadn't been ported yet -- not surprising since I started this site while D8 was still in beta.

Step 2: Search for a code-based Drupal 8 solution

I wasn't able to find anything in my D8-specific searches, which is part of why I am writing this up. :)

Step 3: How was is done in Drupal 7?

Broadening my search a bit, I was able to find a few Stack Overflow and Drupal Answers issues referencing hook_custom_theme(). Now we're getting somewhere! The API docs will tell you which version of Drupal a function or class is in, and often contains pointers to updated or renamed functions in the comments. No such luck in this case, but based on my experience contributing to Drupal 8 core, I knew the next step was to...

Step 4: Search the Drupal 8 change records

Searching the change records for hook_custom_theme() lead me to 'theme callback' and hook_custom_theme() replaced by theme negotiators. Thanks to the awesome change record, I have a great starting point for creating a custom module. In addition to the change record, the API docs for the specific class (ThemeNegotiator) can also be helpful to read.

Code

Step 1: Create the necessary custom module structure

You can find extensive documentation on drupal.org, but the requirements for creating a custom module are pretty straightforward:

  1. Create a directory for the module, and put the directory somewhere where Drupal will find it. I created a directory called example_themeswitcher and placed it inside the {drupal8_root}/modules directory.
  2. Create an info.yml file inside the directory you just created. Here is my example_themeswitcher.info.yml:
    name: Example themswitcher
    description: 'Switches the active theme based on the page's menu.'
    type: module
    core: 8.x 

Step 2: Define the custom ThemeNegotiator service

The only way Drupal is going to know that this module provides this custom code is for us to tell it so. Since this is a service, this definition goes in example_themeswitcher/example_themeswitcher.services.yml:

services: 
  theme.negotiator.example_themeswitcher:
    class: Drupal\example_themeswitcher\Theme\ExampleThemeswitcherNegotiator
    tags:
      - { name: theme_negotiator, priority: 10 } 

 

Step 3: Extend the ThemeNegotiator service class

Drupal 8 adheres fairly strictly to PSR-4 namespaces and autoloading, which basically means that files and classes must be named specific ways, and placed in specific directories, in order to be recognized. That being said, below is the class file for ExampleThemeswitcherNegotiator, which is placed in the example_themeswitcher/src/Theme directory with the filename ExampleThemeswitcherNegotiator.php. You'll need to be sure to include an opening <?php tag with the following:

/**
 * @file
 * Contains \Drupal\example_themeswitcher\Theme\ExampleThemeswitcherNegotiator.
 */

namespace Drupal\example_themeswitcher\Theme;

use Drupal\Core\Routing\RouteMatchInterface;
use Drupal\Core\Theme\ThemeNegotiatorInterface;

class ExampleThemeswitcherNegotiator implements ThemeNegotiatorInterface {
  
  public function applies(RouteMatchInterface $route_match) {
    $applies = FALSE;
    $section_menus = array('section1', 'section2', 'section3');
    foreach ($section_menus as $menu_name) {
      $link = \Drupal::service('menu.active_trail')->getActiveLink($menu_name);
      if (!empty($link)) {
        $applies = TRUE;
      }
    }
    // Use this theme negotiator.
    return $applies;
  }

  /**
   * {@inheritdoc}
   */
  public function determineActiveTheme(RouteMatchInterface $route_match) {
    $section_menus = array('section1', 'section2', 'section3');
    foreach ($section_menus as $menu_name) {
      $link = \Drupal::service('menu.active_trail')->getActiveLink($menu_name);
      if (!empty($link)) {
        return 'subtheme_' . $menu_name;
      }
    }
  }
}

Note that the menu and theme names are hardcoded above -- my section menus are named 'section1', 'section2', and 'section3', and the themes are 'subtheme_section1', 'subtheme_section2', and 'subtheme_section3'. Those menus already existed and the themes were already enabled before I got this module working, but it's also possible to define menus and enable themes when installing a module -- but that's a topic for another post.

Once these three files have been created as part of your module, remember to enable it! Assuming everything is set up correctly, the service class file is the only one you'll need to change to suit your specific use-case.

And that's it

If four hours seems like a lot for 3 files, well, I'm guessing you're either not in a technical role, or you're a technological genius! There was a fair bit of trial and error (and using devel because I haven't figured out how to use my IDE's breakpoints) to figure out the actual code I needed for the class to work the way I wanted it to. Overall, my time was broken into about 40% research, 40% typing the code about, and 20% buliding the menus and themes I needed to test it all out. Not to mention the hour and a half I've spent writing up this post!

For other anecdotes on why time is not a great way to measure value, see The Handyman's Invoice or The Picasso Principle.

Comments

Great post! Thanks for taking the effort to publish your code.

I was searching around for an hour and was fairly stuck and about to give up and resort to jusing a separate template and make a path alias.  This saved my a ton of time, and now I can do it 'right'!

Thanks so much for taking the time to write this up - huge help!

Thank you so much for sharing this! Not just the solution, but also your approach. I just had to something similar and this was extremely useful.

Thank you so much for posting this. I'm mulling over the idea of my module providing a view for a user to see how a font would look with their theme on a specific page and was pulling my hair out on the idea of it. I like the idea of the applies() function in drupal for these interfaces as I was worried one module could override others.

Again, thank you so much :)

Hi!, thanks for your article, question, with this method can I change the theme during install profile?. Thanks again!

Good question! I know it was possible in Drupal 7 - Commerce Kickstart is a good example there.

I would suggest investigating how they did it for D7, then search the change records for D8.