Symfony2 advanced menus

Symfony2 advanced menus...

Building and advanced filtering on Symfony2 menus with KnpMenuBundle, based on user roles

Recently, I was working on our company's website and it was required to have the header menu changed based on the users permissions. The menu is built using the KnpMenuBundle and is configured like this:

services:
    app.backend.menu.builder:
        class: App\BackendBundle\Menu\Builder
        arguments: ["@service_container"]

    app.backend.menu:
        class: Knp\KnpMenu\MenuItem
        factory_service: app.backend.menu.builder
        factory_method: createAdminMenu
        arguments: ["@knp_menu.factory"]
        tags:
            - { name: knp_menu.menu, alias: admin_menu }

The above code is placed in the services.yml file of the same bundle that the menu builder is and it allows us to use the admin_menu alias to render the form in our templates.

Our current menu template allows simple dropdown menus using the Twitter Bootstrap Theme so we ended up with a menu builder that looks like this:

namespace App\BackendBundle\Menu;

use Knp\Menu\FactoryInterface;
use Knp\Menu\ItemInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;

class Builder
{
    /** @var ContainerInterface */
    private $container;

    function __construct(ContainerInterface $container)
    {
        $this->container = $container;
    }

    public function createAdminMenu(FactoryInterface $factory)
    {
        $menu = $factory->createItem('root');
        // dashboard
        $menu->addChild('Dashboard', array(
                'route' => 'backend_home'
            ))
        ;
        // quick links
        $menu->addChild('Quick links', array())->setAttribute('dropdown', true)
            ->addChild('New post', array(
                'route' => 'backend_post_new'
            ))->getParent()
            ->addChild('New category', array(
                'route' => 'backend_category_new'
            ))->getParent()
            ->addChild('New user', array(
                'route' => 'backend_user_new'
            ))->getParent()
            ->addChild('New link', array(
                'route' => 'backend_link_new'
            ))->getParent()
            ->addChild('New developer', array(
                'route' => 'backend_developer_new'
            ))->getParent()
            ->addChild('New project', array(
                'route' => 'backend_project_new'
            ))->getParent()
            ->addChild('New testimonial', array(
                'route' => 'backend_testimonial_new'
            ))->getParent()
            ->addChild('New skill', array(
                'route' => 'backend_skill_new'
            ))->getParent()
        ;

        // blog
        $menu->addChild('Blog', array())->setAttribute('dropdown', true)
            ->addChild('Posts', array(
                'route' => 'backend_post'
            ))->getParent()
            ->addChild('Categories', array(
                'route' => 'backend_category'
            ))->getParent()
        ;
        $menu->addChild('Misc', array())->setAttribute('dropdown', true)
            ->addChild('Links', array('route' => 'backend_link'))->getParent()
            ->addChild('Developers', array('route' => 'backend_developer'))->getParent()
            ->addChild('Skills', array('route' => 'backend_skill'))->getParent()
            ->addChild('Project', array('route' => 'backend_project'))->getParent()
            ->addChild('Testimonials', array('route' => 'backend_testimonial'))->getParent()
            ->addChild('Users', array('route' => 'backend_user'))->getParent();

        if ($this->container->get('session')->has('real_user_id')) {
            $menu->addChild('Deimpersonate', array('route' => 'backend_user_deimpersonate'));
        }
        $menu->addChild('Log out', array('route' => 'fos_user_security_logout'))->getParent();

        return $menu;
    }
}

The above code builds a nice menu but it's simply not enough if you want your editors to just see the Blog section, for example. I could've manually filtered the user menu with if/else statements at this point, but that doesn't really feel like Symfony, does it?

To make our builder feel like it's a Symfony menu I have decided that I will filter the menu based on the @Secure annotations, that I added to the controller methods and to do this, I needed access to 3 components:

  • the router component - this allows me to retrieve Route objects so that I can get the class and method that is mapped for the route that I have passed to the addChild() method of the menu factory
  • a metadata reader - this allows me to read the metadata for the controller methods that I will be retrieving with the router component
  • the security context - this allows me to check if the current user has a certain role that is configured with @Route(roles="...")

With this in mind I've created 3 properties on the menu builder that would hold references to the components that I need in order to achieve my goal, so the constructor and properties section of my menu builder now looks like this:

    /** @var ContainerInterface */
    private $container;

    /** @var Router */
    private $router;

    /**
     * @var SecurityContext
     */
    private $securityContext;

    /**
     * @var \JMS\SecurityExtraBundle\Metadata\Driver\AnnotationDriver
     */
    private $metadataReader;

    function __construct(ContainerInterface $container)
    {
        $this->container = $container;
        $this->router = $this->container->get('router');
        $this->securityContext = $this->container->get('security.context');
        $this->metadataReader = new AnnotationDriver(new \Doctrine\Common\Annotations\AnnotationReader());
    }

Now that this is set, I can go on and write the 2 methods that I need, filterMenu and the hasRouteAccess.

The filterMenu method is quite simple, it just loops through the menu's child elements recursively and checks whether or not the current user hasRouteAccess.

    public function filterMenu(ItemInterface $menu)
    {
        foreach ($menu->getChildren() as $child) {
            /** @var \Knp\Menu\MenuItem $child */
            list($route) = $child->getExtra('routes');

            if ($route && !$this->hasRouteAccess($route)) {
                $menu->removeChild($child);
            }

            $this->filterMenu($child);
        }
        return $menu;
    }

Now comes the slightly more complicated part of our current menu builder, the hasRouteAccess method. This method will has to check if the current user is logged in (although this is most likely not needed as this part of the application is already behind a firewall that requires authentication) and then grab the route object based on the $routeName parameter and then check if any of the roles required by @Route annotation.

    /**
     * @param $class
     * @return \JMS\SecurityExtraBundle\Metadata\ClassMetadata
     */
    public function getMetadata($class)
    {
        return $this->metadataReader->loadMetadataForClass(new \ReflectionClass($class));
    }

    public function hasRouteAccess($routeName)
    {
        $token = $this->securityContext->getToken();
        if ($token->isAuthenticated()) {
            $route = $this->router->getRouteCollection()->get($routeName);
            $controller = $route->getDefault('_controller');
            list($class, $method) = explode('::', $controller, 2);

            $metadata = $this->getMetadata($class);
            if (!isset($metadata->methodMetadata[$method])) {
                return false;
            }

            foreach ($metadata->methodMetadata[$method]->roles as $role) {
                if ($this->securityContext->isGranted($role)) {
                    return true;
                }
            }
        }
        return false;
    }

Putting everything together, we end up with something like this:

namespace App\BackendBundle\Menu;

use JMS\SecurityExtraBundle\Metadata\Driver\AnnotationDriver;
use Knp\Menu\FactoryInterface;
use Knp\Menu\ItemInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Symfony\Component\Security\Core\SecurityContext;

class Builder
{
    /** @var ContainerInterface */
    private $container;

    /** @var Router */
    private $router;

    /**
     * @var SecurityContext
     */
    private $securityContext;

    /**
     * @var \JMS\SecurityExtraBundle\Metadata\Driver\AnnotationDriver
     */
    private $metadataReader;

    function __construct(ContainerInterface $container)
    {
        $this->container = $container;
        $this->router = $this->container->get('router');
        $this->securityContext = $this->container->get('security.context');
        $this->metadataReader = new AnnotationDriver(new \Doctrine\Common\Annotations\AnnotationReader());
    }

    /**
     * @param $class
     * @return \JMS\SecurityExtraBundle\Metadata\ClassMetadata
     */
    public function getMetadata($class)
    {
        return $this->metadataReader->loadMetadataForClass(new \ReflectionClass($class));
    }

    public function hasRouteAccess($routeName)
    {
        $token = $this->securityContext->getToken();
        if ($token->isAuthenticated()) {
            $route = $this->router->getRouteCollection()->get($routeName);
            $controller = $route->getDefault('_controller');
            list($class, $method) = explode('::', $controller, 2);

            $metadata = $this->getMetadata($class);
            if (!isset($metadata->methodMetadata[$method])) {
                return false;
            }

            foreach ($metadata->methodMetadata[$method]->roles as $role) {
                if ($this->securityContext->isGranted($role)) {
                    return true;
                }
            }
        }
        return false;
    }

    public function filterMenu(ItemInterface $menu)
    {
        foreach ($menu->getChildren() as $child) {
            /** @var \Knp\Menu\MenuItem $child */
            $routes = $child->getExtra('routes');
            if ($routes !== null) {
                $route = current(current($routes));

                if ($route && !$this->hasRouteAccess($route)) {
                    $menu->removeChild($child);
                }

            }
            $this->filterMenu($child);
        }
        return $menu;
    }

    public function createAdminMenu(FactoryInterface $factory)
    {
        $menu = $factory->createItem('root');
        // dashboard
        $menu->addChild('Dashboard', array(
                'route' => 'backend_home'
            ))
        ;
        // quick links
        $menu->addChild('Quick links', array())->setAttribute('dropdown', true)
            ->addChild('New post', array(
                'route' => 'backend_post_new'
            ))->getParent()
            ->addChild('New category', array(
                'route' => 'backend_category_new'
            ))->getParent()
            ->addChild('New user', array(
                'route' => 'backend_user_new'
            ))->getParent()
            ->addChild('New link', array(
                'route' => 'backend_link_new'
            ))->getParent()
            ->addChild('New developer', array(
                'route' => 'backend_developer_new'
            ))->getParent()
            ->addChild('New project', array(
                'route' => 'backend_project_new'
            ))->getParent()
            ->addChild('New testimonial', array(
                'route' => 'backend_testimonial_new'
            ))->getParent()
            ->addChild('New skill', array(
                'route' => 'backend_skill_new'
            ))->getParent()
        ;

        // blog
        $menu->addChild('Blog', array())->setAttribute('dropdown', true)
            ->addChild('Posts', array(
                'route' => 'backend_post'
            ))->getParent()
            ->addChild('Categories', array(
                'route' => 'backend_category'
            ))->getParent()
        ;
        $menu->addChild('Misc', array())->setAttribute('dropdown', true)
            ->addChild('Links', array('route' => 'backend_link'))->getParent()
            ->addChild('Developers', array('route' => 'backend_developer'))->getParent()
            ->addChild('Skills', array('route' => 'backend_skill'))->getParent()
            ->addChild('Project', array('route' => 'backend_project'))->getParent()
            ->addChild('Testimonials', array('route' => 'backend_testimonial'))->getParent()
            ->addChild('Users', array('route' => 'backend_user'))->getParent();

        $this->filterMenu($menu);

        if ($this->container->get('session')->has('real_user_id')) {
            $menu->addChild('Deimpersonate', array('route' => 'backend_user_deimpersonate'));
        }
        $menu->addChild('Log out', array('route' => 'fos_user_security_logout'))->getParent();

        return $menu;
    }
}

Our menu templates looks something like this:

{% extends 'knp_menu.html.twig' %}

{% macro attributes(attributes) %}
    {% for name, value in attributes %}
        {%- if value is not none and value is not sameas(false) -%}
            {{- ' %s="%s"'|format(name, value is sameas(true) ? name|e : value|e)|raw -}}
        {%- endif -%}
    {%- endfor -%}
{% endmacro %}

{% block root %}
    {% set listAttributes = item.childrenAttributes %}
    {{ block('list') -}}
{% endblock %}

{% block children %}
    {# save current variables #}
    {% set currentOptions = options %}
    {% set currentItem = item %}
    {# update the depth for children #}
    {% if options.depth is not none %}
        {% set options = currentOptions|merge({'depth': currentOptions.depth - 1}) %}
    {% endif %}
    {% for item in currentItem.children %}
        {{ block('item') }}
    {% endfor %}
    {# restore current variables #}
    {% set item = currentItem %}
    {% set options = currentOptions %}
{% endblock %}

{% block spanElement %}
    {% if not item.attribute('dropdown') %}
        <span{{ _self.attributes(item.labelAttributes) }}>{{ block('label') }}</span>
    {% endif %}
{% endblock %}

{% block list %}
    {% if item.hasChildren and options.depth is not sameas(0) and item.displayChildren %}
        {% if item.attribute('dropdown') %}
            <ul class="nav x">
                <li class="dropdown">
                    <a href="#" class="dropdown-toggle" data-toggle="dropdown">{{ item.label }} <b  class="caret"></b></a>
                    <ul class="dropdown-menu">
                        {{ block('children') }}
                    </ul>
                </li>
            </ul>
        {% else %}
            <ul class="nav">
                {{ block('children') }}
            </ul>
        {% endif %}
    {% endif %}
{% endblock %}

And our methods are secured with something like this:

use Symfony\Bundle\FrameworkBundle\Controller\Controller;
use JMS\SecurityExtraBundle\Annotation\Secure;

class SomeController extends Controller
{
    /**
     * Lists all BlogCategory entities.
     *
     * @Route("/", name="routeName")
     * @Template()
     * @Secure(roles="ROLE_EDITOR")
     */
    public function indexAction() {
        return array();
    }
}

Once you put everything together, you will have a nice menu that will show the user only what he actually has access to and it will eliminate the need to write all those ugly if/else statements.

The 2 main things to notice here, is that this class requires you to have JMSSerializerBundle installed and you need to use the @Secure annotation on your routes.

Enjoy,

Later edit: Between the first version of this article and now (22nd February, 2014), the annotation reader has changed so the $metadata->methodMetadata[$method] will always be empty if we're loading everything through the security.extra.metadata_factory service. We have changed the code in this article accordingly to ensure this still works.

You can find an example app here https://github.com/tsslabs/symfony2-advanced-menus

Let us know what you think.

Want more awesome stuff?

Check us out on our Medium Remote Symfony Team publication.