Add custom menu item attributes in Drupal 8

We're currently working on a Drupal 8 project, where we need the possibility of adding class attributes to menu items. In D7, one would probably choose the menu attributes module to accomplish this. But unfortunately, there is currently no D8 port available. There's a first approach to a D8 port in the issue queue, but it's currently also not working at all. I've also tried to fix the problems, but in the end, there were too many. So I decided to shorten this and write a tiny custom module as a workaround. In this quick tutorial I'll show you, how to create your own tiny module to solve this problem.

Like most other parts in core, the menu system has been changed heavily in D8. Basically, menu links are now plugins (Drupal\Core\Menu\MenuLinkInterface). And default (mostly adminstrative) menu links, provided by modules, are mostly defined via configuration files. These links are saved into the menu_tree table. The menu_link_content module, which is also part of Drupal core, adds its own implementation of the MenuLinkInterface and additionally provides an entity definition for menu links. So, all menu links added by menu_link_content are not only saved into the menu_tree table, but also to it own entity and data tables. The optional menu_ui core module is based on menu_link_content, which means that every menu link, that is added via the UI, is automatically saved as MenuLinkContent entity.

Possibilities to add class and other attributes to menu links

At first glance, it seems very easy to achieve this. Given the fact, that we are already having entities, adding fields to the MenuLinkContent entity would be the most flexible approach. In 99% of the cases it should be enough, that only you can only add the attributes to links based on the menu_link_content module, as all the plain MenuLinkDefault links are normally administrative ones.

Unfortunately the MenuLinkContent entity is defined without field support. The big problem, that caused deep distress in my efforts to add field support, is: adding field support in the original source would only cost a few lines of code, added to the class annotation of the entity. But of course I didn't want to override core files, so I needed a solution to alter existing entity definition or inherit from this class. However, I didn't manage this at all. Retrospectively I'm asking myself, why I didn't found hook_entity_type_alter() function, as this should be the right place to accomplish this.

But there still two other possibilites. Both, the menu_tree table and the menu_link_content table are having a column named "options", which isn't filled at all for any existing entires, but would be automatically picked up in the rendered menu output.

First try: add options to the menu_tree table

I added a input field for the class attribute via hook_form_alter() and a custom submit handler. In the submit handler, I've loaded the corresponding menu link entity, then loaded its plugin definition, set the class attirbute in the options and saved the changed plugin definition. It took some time to find out, how to accomplish this, but it seemed to work at first glance very well. The options were saved into the menu_tree table and it was added to the menu output. The only small thing, I didin't enjoy, was that the attributes were added directly to the link, not to the enclosing list item. But this could be solved by custom theme overrides. But unfortunately I soon saw, that after clearing the site's cache, the options were cleared from the database table! I'm not sure, if this is a bug or normal behaviour - maybe my approach was in general wrong.

I decided to no further investigate this, but instead try the last option: use the existing "options" column of the entity table. From the given code, there was not so much to change. I already had the loaded menu link entity in my submit handler. So the only thing left was to set the options to entity instead of the plugin definition and save. And voila, this was finally the right choice for me. The value is really persisted and not cleared on cache clearing. The options weren't included in output by default, with the implementation of a small preprocess function, the class attribute will be added to our <li> items.

Steps to reproduce

I'll now show you the necessary code snippets. My module's name is "agoramenu", so you probably have to adjust this to your desired module name.

Altering the form

This adds the input field for the class attribute and defines our custom submit handler:

 * Implements hook_form_BASE_FORM_ID_alter() for menu_link_content_form.
function agoramenu_form_menu_link_content_form_alter(array &$form, FormStateInterface $form_state, $form_id) {
  $menuLink = _agoramenu_get_menu_link_entity_from_form_state($form_state);
  $linkOptions = $menuLink->link->first()->options;
  $linkAttributes = !empty($linkOptions['attributes']) ? $linkOptions['attributes'] : array();

  // Add attributes to the menu item.
  $form['options']['attributes'] = array(
    '#type' => 'details',
    '#title' => t('Menu item attributes'),
    '#collapsible' => TRUE,
    '#collapsed' => FALSE,
    '#tree' => TRUE,

  $form['options']['attributes']['class'] = array(
    '#type' => 'textfield',
    '#title' => t('Classes'),
    '#description' => t('Enter additional classes to be added to the menu item.'),
    '#default_value' => !empty($linkAttributes['class']) ? implode(' ', $linkAttributes['class']) : '',

  $form['actions']['submit']['#submit'][] = 'agoramenu_form_menu_link_content_form_submit';

Next, we define our custom submit handler, where first build up the attributes array based on the user input, then load the menu link entity, set the value and finally save the changes:

 * Additional submit handler for menu_link_content_form.
function agoramenu_form_menu_link_content_form_submit(array &$form, FormStateInterface $form_state) {
  $values = $form_state->getValues();
  $attributes = array();

  foreach ($values['attributes'] as $attribute => $value) {
    $value = trim($value);
    if (!empty($value)) {
      if ($attribute == 'class') {
        $value = explode(' ', $value);
      $attributes[$attribute] = $value;

  $menuLinkEntity = _agoramenu_get_menu_link_entity_from_form_state($form_state);
  $options = $menuLinkEntity->link->first()->options;
  $changed = FALSE;
  if (empty($attributes)) {
    if (!empty($options['attributes'])) {
      $changed = TRUE;
  else {
    $options['attributes'] = $attributes;
    $changed = TRUE;

  if ($changed) {
    $menuLinkEntity->link->first()->options = $options;

Here's the small helper function, used above:

function _agoramenu_get_menu_link_entity_from_form_state(FormStateInterface $form_state) {
  $buildInfo = $form_state->getBuildInfo();
  $menuLinkContentForm = $buildInfo['callback_object'];
  return $menuLinkContentForm->getEntity();

Finally, here's the preprocess function including an additional helper function:

 * Implements hook_preprocess_menu().
 * Workaround for randomly lost menu item options: load link entities and
 * restore lost link attributes.
function agoramenu_preprocess_menu(&$variables) {
  foreach ($variables['items'] as &$item) {
    $menuLinkEntity = agoramenu_load_link_entity_by_link($item['original_link']);
    if (!empty($menuLinkEntity)) {
      if (!empty($menuLinkEntity->link->first()->options['attributes'])) {
        foreach ($menuLinkEntity->link->first()->options['attributes'] as $attribute => $value) {
          if ($attribute == 'class') {
            foreach ($value as $cssClass) {
              if (!$item['attributes']->hasClass($cssClass)) {
          else {
            $item['attributes']->setAttribute($attribute, $value);

function agoramenu_load_link_entity_by_link(MenuLinkInterface $menuLinkContentPlugin) {
  $entity = NULL;
  if ($menuLinkContentPlugin instanceof MenuLinkContent) {
    list($entity_type, $uuid) = explode(':', $menuLinkContentPlugin->getPluginId(), 2);
    $entity = \Drupal::entityManager()->loadEntityByUuid($entity_type, $uuid);
  return $entity;


This is exactly what I needed to see for finishing my port of Menu Badges module ( My process was mostly the same as yours, but I got stuck at the "cache clear deletes my options" issue. I was waiting to see how Menu Attributes decided to handle it, but I'll give this a try in the meantime.

I'm pleased, that I could help you with my post. I believe, this solution is not the worst, but I'm not fully satisfied. There may be better ways - so I'm curious about Menu Atributes or any other similiar module will handle this.

What do you think, ist the cache-clearing issue a bug or a feature???

Neuen Kommentar schreiben