Programmatically creating a menu link using Drupal 10 Menu plugin with custom route to a custom landing page

Introduction

Pasan Gamage
8 min readApr 16, 2024

There are multiple ways to get things don in Drupal. Some are easy and straightforward, but some can be more involving and complicated.

Imagine you need to create a menu link into a menu in Drupal. As you may have know it is very direct and easy and can be done through the admin UI.

Let’s take this a bit further. The above scenario is all good until you need to add a dynamic menu link to a menu. So how can we achieve this ?

Let’s have a look around in Drupal itself and try to find a clue. If we pay attention to the default ‘User account menu’, we can see there are 2 menu links.

Both these menu links dynamically generate the routes and we can confirm this by the behaviour of the menu when an anonymous user visit the site vs after the user log in.

User account menu behaviour

And the menu from the admin UI looks like this;

Observing the Drupal core, we can see the that we will need 3 main components to get our custom routes to work.

Plus a Menu plugin class for dynamic menu link generation and dynamic link title generation.

ex. web/core/modules/user/src/Plugin/Menu/LoginLogoutMenuLink.php

If we look at the internals of the default Drupal “My account” link;

1. A menu link key pair. This will make the menu item visible on the User account menu having machine name account.

ex. web/core/modules/user/user.links.menu.yml

user.page:
title: 'My account'
weight: -10
route_name: user.page
menu_name: account

2. A routing key pair; This will tell Drupal what do load when the link is activated.

ex. web/core/modules/user/user.routing.yml

user.page:
path: '/user'
defaults:
_controller: '\Drupal\user\Controller\UserController::userPage'
_title: 'My account'
requirements:
_user_is_logged_in: 'TRUE'

3. A controller class; The logic. In this case it is derived inside userPage method in UserController class.

ex. web/core/modules/user/src/Controller/UserController.php

  /**
* Redirects users to their profile page.
*
* This controller assumes that it is only invoked for authenticated users.
* This is enforced for the 'user.page' route with the '_user_is_logged_in'
* requirement.
*
* @return \Symfony\Component\HttpFoundation\RedirectResponse
* Returns a redirect to the profile of the currently logged in user.
*/
public function userPage() {
return $this->redirect('entity.user.canonical', ['user' => $this->currentUser()->id()]);
}

Something extra

Depending on the requirements, we can change the above .yml key value properties.

Checkout https://json.schemastore.org/drupal-routing.json for what are the available core routing properties and https://json.schemastore.org/drupal-links-menu.json for menu link properties

If you are more interested in how Drupal Entities work and how they are configured with Drupal annotations, folder structures and about the Entity API checkout this documentation and since we are talking about User Entity, the User Entity class web/core/modules/user/src/Entity/User.php

Another important thing to note before we begin is that when creating functionality extensions to Drupal, always try to follow how it is done in core. For example, follow the same folder structure pattern, naming conventions and coding style.

Building the code — What we are after

What I’m after is to update the existing User account menu to render as below.

For anonymous users;

And for logged-in users;

  • “My Playground Account” link directly take the logged-in user to a custom user profile page view.
  • While My account and Logout will retain the default Drupal behaviours.

The menu from the admin UI will look like this;

If you are wondering why the wording (Log in for anonymous users) or (logged in users only) are not showing in front of our custom “My Playground Account” link is because logic is hard coded in Drupal core and there is a current issue in the D.O issue queue https://www.drupal.org/node/2568785 to modify modules/menu_ui/src/MenuForm.php

The Code

Now to the most anticipated part.

First, I created a new custom module. There we will have below files.

Inside the playground_menu.links.menu.yml file, we need to include a menu definition for our “My Playground Account” menu link.

playground_menu.profile:
route_name: playground_menu.profile_route
title: 'My Playground Account'
weight: -10
menu_name: account
class: Drupal\playground_menu\Plugin\Menu\MyAccountMenuLink

route_name should be same as the key we will provide when creating the playground_menu.routing.yml file

title value will be the HTML title value of the browser tab.

weight You can adjust the position of this menu link in My account menu.

menu_name This is the target menu machine name. In our case, we will be extending the default Drupal Account Menu.

class Can be used to define an action upon the menu link click. In this case it is the menu link plugin.

The second task is to create the playground_menu.routing.yml route file. Here we can add;

playground_menu.profile_route:
path: '/user/{user}/account'
defaults:
_controller: '\Drupal\playground_menu\Controller\UserController::userPage'
requirements:
_user_is_logged_in: 'TRUE'
_permission: 'view content'
user: \d+
options:
parameters:
user:
type: entity:user

Note that the playground_menu.profile_route key is the same as the one provided for the route_name in the playground_menu.links.menu.yml

path will navigate the user to the dynamic URL where {user} is generated dynamically depending on the logged-in User ID

Everything under defaults will be taken as default behaviours for this route.

_controller A controller to execute when the route is matched. In my case, I will use this to display the user a custom View page. We will get back to that later in the article.

requirements Can be a variety of option. Basically, it’s a List of requirements that makes a specific route only match under specific conditions. I’m using _user_is_logged_in as a property to check user is authenticated and _permission to check current user permission is ‘view content’. And finally user This property is not defined in the Drupal routing JSON file but can be found elsewhere in core code. It is basically the wildcard we provided in path and we are telling Drupal to only accept digits and \d+ is a regular expression.

options Are additional route options. Since we are checking users, we choose User entity type.

There are a lot of other advanced route yml files in core. It can be very confusing and overwhelming, but testing and practising is the best way to overcome it.

If we enable our module only with playground_menu.links.menu.ymlfile and playground_menu.info.yml we will see PHP errors for missing Controller and the MenuLink classes.

Let’s add them.

The UserController.php code have below.

<?php

namespace Drupal\playground_menu\Controller;


use Drupal\Core\Controller\ControllerBase;
use Drupal\Core\Url;
use Symfony\Component\HttpFoundation\RedirectResponse;

/**
* Controller routines for user routes.
*/
class UserController extends ControllerBase {
public function userPage($user) {
// return $this->redirect('entity.user.canonical', ['user' => $this->currentUser()->id()]);
$url = Url::fromRoute('view.my_playground_account.account_page', ['arg_0' => $this->currentUser()->id()]);
return new RedirectResponse($url->toString());
}
}

You will notice that I’ve commented a line for demonstration purpose, and that line is from web/core/modules/user/src/Controller/UserController.php file userPage() method, which I mentioned earlier in this article. But for now we do not need that. Instead, I would like the user to be redirected to a simple view page I’ve created.

Notice the route and the argument passed in. Since my view uses User ID as a contextual filter, I need to pass it in an argument for the route.

Finally, the MyAccountMenuLink.php class,

<?php

namespace Drupal\playground_menu\Plugin\Menu;

use Drupal\Core\Menu\MenuLinkDefault;
use Drupal\Core\Menu\StaticMenuLinkOverridesInterface;
use Drupal\Core\Session\AccountInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;

class MyAccountMenuLink extends MenuLinkDefault {

/**
* The current user.
*
* @var \Drupal\Core\Session\AccountInterface
*/
protected $currentUser;

/**
* Constructs a new MyAccountMenuLink.
*
* @param array $configuration
* A configuration array containing information about the plugin instance.
* @param string $plugin_id
* The plugin_id for the plugin instance.
* @param mixed $plugin_definition
* The plugin implementation definition.
* @param \Drupal\Core\Menu\StaticMenuLinkOverridesInterface $static_override
* The static override storage.
* @param \Drupal\Core\Session\AccountInterface $current_user
* The current user.
*/
public function __construct(array $configuration, $plugin_id, $plugin_definition, StaticMenuLinkOverridesInterface $static_override, AccountInterface $current_user) {
parent::__construct($configuration, $plugin_id, $plugin_definition, $static_override);

$this->currentUser = $current_user;
}

/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) {
return new static(
$configuration,
$plugin_id,
$plugin_definition,
$container->get('menu_link.static.overrides'),
$container->get('current_user')
);
}

/**
* {@inheritdoc}
*/
public function getTitle() {
if ($this->currentUser->isAuthenticated()) {
return $this->t('My Playground Account');
}
else {
return $this->t('Create My Account');
}
}

/**
* {@inheritdoc}
*/
public function getRouteName() {
if ($this->currentUser->isAuthenticated()) {
return 'playground_menu.profile_route';
}
else {
return 'user.register';
}
}

/**
* {@inheritdoc}
*/
public function getRouteParameters() {
return $this->currentUser->isAuthenticated() ?
($this->pluginDefinition['route_parameters'] += ['user' => $this->currentUser->id()]) : [];
}

/**
* {@inheritdoc}
*/
public function getCacheContexts() {
return ['user.roles:authenticated'];
}

}

This is again a replication of how core handles menu links. And our class is an extension of MenuLinkDefault class.

Notice the getTitle() and getRouteName() methods which checks the user authentication status and dynamically update the title and the route.

Now we can verify that a new menu item My Playground Account is visible under User account menu.

--

--

Pasan Gamage
Pasan Gamage

Written by Pasan Gamage

Backend Developer | Motorbike enthusiast

No responses yet