Pop PHP
The Toolkit

Navigation

pop-nav renders a navigation menu from a nested array. You describe the tree once as data, and the component resolves the URLs, marks the current page and — when you give it an ACL object — leaves out the branches the current role cannot reach. The markup it produces is built from pop-dom nodes, so the element names, ids and classes are all yours to set.

BASH
composer require popphp/pop-nav

Building a Nav Tree from Config#

A nav tree is data, which puts it in a config file. Each node needs a name and an href, and may carry children:

PHPapp/config/nav.php
<?php

return [
    [
        'name'     => 'Users',
        'href'     => '/users',
        'children' => [
            ['name' => 'Roles',  'href' => 'roles'],
            ['name' => 'Config', 'href' => 'config'],
        ],
    ],
    [
        'name' => 'Orders',
        'href' => '/orders',
    ],
];

How an href resolves depends on its shape, and the three cases are what let one array describe a whole site:

href Resolves to
/users the baseUrl prefix plus /users — absolute within the application
roles its parent's already-resolved href plus /roles, so the child above becomes /users/roles
https://..., mailto:..., #, or anything starting or ending with # itself, untouched, with no baseUrl prefix

A relative href on a top-level node has no parent to join onto, so give every top-level node an absolute one.

Nodes can also be added after construction, which is what you want when the menu is assembled from more than one source — installed modules, a plugin registry, per-user extras:

PHP
use Pop\Nav\Nav;

$nav = new Nav(include __DIR__ . '/../config/nav.php');

$nav->addBranch(['name' => 'Reports', 'href' => '/reports']);
$nav->addLeaf('Users', ['name' => 'Permissions', 'href' => 'permissions']);

addBranch() appends a top-level node, or prepends it when passed true as a second argument. addLeaf() finds nodes by name and appends to their children, creating the key if it is not there yet.

addLeaf() matches on name at every depth and inserts into every match, so give nodes distinct names when you intend to add to one of them.

Rendering#

With no configuration, every node is a <nav> wrapping an <a>:

PHP
use Pop\Nav\Nav;

echo new Nav(include __DIR__ . '/../config/nav.php');
HTML
<nav>
    <nav>
        <a href="/users">Users</a>
        <nav>
            <nav>
                <a href="/users/roles">Roles</a>
            </nav>
            <nav>
                <a href="/users/config">Config</a>
            </nav>
        </nav>
    </nav>
    <nav>
        <a href="/orders">Orders</a>
    </nav>
</nav>

The second constructor argument names the elements and their attributes. top is the outermost container, parent is the container that wraps a set of children, and child is the element wrapping each individual link — which is how you get a conventional nested list:

PHP
use Pop\Nav\Nav;

$nav = new Nav(include __DIR__ . '/../config/nav.php', [
    'top'    => ['node' => 'ul', 'id' => 'main-nav'],
    'parent' => ['node' => 'ul', 'class' => 'level'],
    'child'  => ['node' => 'li', 'class' => 'item'],
    'indent' => '    ',
]);

echo $nav;
HTML
    <ul id="main-nav">
        <li class="item-1">
            <a href="/users">Users</a>
            <ul class="level-2">
                <li class="item-2">
                    <a href="/users/roles">Roles</a>
                </li>
                <li class="item-2">
                    <a href="/users/config">Config</a>
                </li>
            </ul>
        </li>
        <li class="item-1">
            <a href="/orders">Orders</a>
        </li>
    </ul>

An id or class in parent or child gets a numeric suffix appended. The class suffix is the depth, so item-1 and item-2 are a reliable CSS hook. The id suffix is not — it's a counter that increments once per node across the whole tree, so menu-3 becomes menu-4 the moment you add a node above it. Style on the class, not the id.

baseUrl prefixes every absolute href, for an application served from a subdirectory:

PHP
use Pop\Nav\Nav;

$nav = new Nav(include __DIR__ . '/../config/nav.php', ['baseUrl' => '/app']);

echo $nav;  // <a href="/app/users">Users</a>, <a href="/app/users/roles">Roles</a>, ...

Individual nodes can carry their own attributes, applied to the <a> tag, and returnFalse(true) adds onclick="return false;" to any link whose resolved href is or ends with # — for menu headings that open a submenu rather than navigating:

PHP
use Pop\Nav\Nav;

$nav = new Nav([
    ['name' => 'Dashboard', 'href' => '/dashboard', 'attributes' => ['data-tooltip' => 'Go to dashboard']],
    ['name' => 'More',      'href' => '#'],
]);

$nav->returnFalse(true);

echo $nav;  // <a href="#" onclick="return false;">More</a>

Active State#

Set on and off in the config and every link gets one of the two classes, depending on whether it is the page the reader is currently on:

PHP
use Pop\Nav\Nav;

$nav = new Nav(include __DIR__ . '/../config/nav.php', [
    'on'  => 'link-on',
    'off' => 'link-off',
]);

echo $nav;

On a request to /orders:

HTML
<nav>
    <nav>
        <a href="/users" class="link-off">Users</a>
        <nav>
            <nav>
                <a href="/users/roles" class="link-off">Roles</a>
            </nav>
        </nav>
    </nav>
    <nav>
        <a href="/orders" class="link-on">Orders</a>
    </nav>
</nav>

A node that already has a class in its own attributes keeps it — the on/off class is appended rather than substituted, so ['class' => 'icon-dashboard'] renders as class="icon-dashboard link-on".

The comparison is against $_SERVER['REQUEST_URI'] with any query string stripped, so /orders and /orders?status=open both light up the /orders link. Outside a web request there is no REQUEST_URI to read, so set the URL yourself, either in the config or with the setter:

PHP
use Pop\Nav\Nav;

$nav = new Nav(include __DIR__ . '/../config/nav.php', ['on' => 'link-on', 'off' => 'link-off']);

$nav->setCurrentUrl('/users/roles');

echo $nav;  // Roles is the link marked link-on

currentUrl in the config array does the same thing at construction time. Either one takes precedence over $_SERVER['REQUEST_URI'] whenever it is set.

The active-state match is exact string equality against the resolved href, so pass the URI without a trailing slash.

ACL-Aware Navigation#

A node with an acl key is rendered only if the current role passes the check. Name the resource, and optionally a permission, alongside the rest of the node — one added key on the same config file:

PHP
['name' => 'Config', 'href' => 'config', 'acl' => ['resource' => 'config']],

Give the Nav the ACL object and the role, and the tree filters itself:

PHP
use Pop\Acl\Acl;
use Pop\Acl\AclResource;
use Pop\Acl\AclRole;
use Pop\Nav\Nav;

$acl = new Acl();

$admin  = new AclRole('admin');
$editor = new AclRole('editor');

$acl->addRoles([$admin, $editor]);
$acl->addResource(new AclResource('config'));
$acl->allow('admin');
$acl->deny('editor', 'config');

$nav = new Nav(include __DIR__ . '/../config/nav.php');
$nav->setAcl($acl);
$nav->setRole($editor);

echo $nav;  // Users, Roles and Orders render; the Config node does not

Swap $editor for $admin and Config reappears. Nothing about the surrounding markup changes — the denied node is left out of the tree before rendering, so there's no hidden element for a reader to find in the page source.

ACL evaluation follows pop-acl's permissive default, so call setStrict() on the ACL when you want nodes hidden unless a rule allows them.

Resolve the user's single applicable role before calling setRole(), or express the logic as a policy. addRole() and addRoles() add to the set rather than replacing it, and more roles only narrow what renders: by default a check fails as soon as any one role is explicitly denied, and in strict mode it passes only when every role is independently allowed.

A policy key, global in the config or per node under acl, hands the decision to a callable that returns the role whose own logic decides — a role name already on the Acl object, or an object using Pop\Acl\Policy\PolicyTrait. The result replaces the plain resource check for that node.

Set the ACL object and the role before rendering a tree carrying acl keys.

See Also#

  • Authorization — the pop-acl roles, resources, strict mode and policies this page hands off to
  • Routing — the routes the hrefs in the tree point at
  • Views & Templates — printing the rendered nav from a layout
  • DOM — the Child nodes the rendered markup is built from
  • pop-nav README — every config key and the policy API