popphp / pop-nav
Pop Nav Component for Pop PHP Framework
Requires
- php: >=8.4.0
- popphp/pop-acl: ^5.0.0
- popphp/pop-dom: ^5.0.0
- popphp/pop-utils: ^3.0.0
Requires (Dev)
- phpstan/phpstan: ^2.2
- phpunit/phpunit: ^12.5.0
README
- Overview
- Install
- Quickstart
- Tree Node Options
- Modifying the Tree
- Config
- Using ACL
- Using an ACL Policy
Overview
pop-nav is a component for managing and rendering an HTML navigation tree. It includes support for
injecting ACL functionality to display only the certain branches of the navigation tree that the
current user role is allowed to access. For that, the pop-acl component is used.
pop-nav is a component of the Pop PHP Framework.
Install
Install pop-nav using Composer.
composer require popphp/pop-nav
Or, require it in your composer.json file
"require": {
"popphp/pop-nav" : "^5.0.0"
}
Quickstart
First, you can define the navigation tree:
$tree = [ [ 'name' => 'Users', 'href' => '/users', 'children' => [ [ 'name' => 'Roles', 'href' => 'roles' ], [ 'name' => 'Config', 'href' => 'config' ] ] ], [ 'name' => 'Orders', 'href' => '/orders' ] ];
Then you can pass that to the nav object and render the nav:
$nav = new Nav($tree); echo $nav;
<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>
Tree Node Options
Each node in the tree supports a few options beyond name, href, and children.
Href resolution — how href is resolved depends on its shape:
#, anything starting with or ending in#,http..., ormailto:...is used exactly as given — external links, mailto links, and JS-hook anchors all bypassbaseUrlentirely.- Anything starting with
/is prefixed withbaseUrl(see Config below). - Anything else is treated as relative and joined onto its parent node's already-resolved href — this is how
the nested
'href' => 'roles'in the Quickstart example above becomes/users/roles. Top-level nodes have no parent href to join onto, so they should always use an absolute (/...) href.
$tree = [ ['name' => 'Docs', 'href' => 'https://docs.example.com'], ['name' => 'Support', 'href' => 'mailto:support@example.com'], ['name' => 'Top', 'href' => '#top'] ];
Per-node attributes — a node can carry its own attributes, applied to its <a> tag. If on/off is also
configured (see Config) and the node's attributes already has a class, the on/off class is
appended to it rather than replacing it:
$tree = [ [ 'name' => 'Dashboard', 'href' => '/dashboard', 'attributes' => ['class' => 'icon-dashboard', 'data-tooltip' => 'Go to dashboard'] ] ];
returnFalse() — for href="#"-style links meant to trigger JS rather than navigate, calling
$nav->returnFalse(true) adds onclick="return false;" to any link whose resolved href is # or ends with #:
$nav = new Nav($tree); $nav->returnFalse(true);
Modifying the Tree
Beyond passing the whole tree to the constructor up front, branches and leaves can be added after the fact — useful when nav items come from more than one source (installed modules, plugins, etc.):
// Append a new top-level branch (pass true as the 2nd argument to prepend it instead) $nav->addBranch([ 'name' => 'Orders', 'href' => '/orders' ]); // Insert a leaf into an existing branch, found by matching 'name' $nav->addLeaf('Users', [ 'name' => 'Permissions', 'href' => 'permissions' ]);
addLeaf() walks the whole tree looking for node(s) named $branch and appends $leaf to their children
(creating that key if it doesn't already exist). If more than one node in the tree shares that name, the leaf
is inserted into all of them — pass a depth as the third argument (the root level is depth 0) to restrict
the match to one level, and/or true as the fourth argument to prepend instead of append:
// Only match a 'Users' branch at the top level (depth 0) $nav->addLeaf('Users', ['name' => 'Permissions', 'href' => 'permissions'], 0);
Config
You have a significant amount of control over the branch nodes and attributes via a configuration array:
$config = [ 'top' => [ 'node' => 'nav', 'id' => 'main-nav' ], 'parent' => [ 'node' => 'nav', 'id' => 'nav', 'class' => 'level' ], 'child' => [ 'node' => 'nav', 'id' => 'menu', 'class' => 'item' ], 'on' => 'link-on', 'off' => 'link-off', 'indent' => ' ', 'baseUrl' => '/app' ];
baseUrl is prefixed onto any node href that starts with / (see Tree Node Options above)
— leave it unset if your app is served from the domain root.
By default, the on/off link class is decided by comparing each link's href against $_SERVER['REQUEST_URI'].
To control that comparison explicitly — useful outside a normal HTTP request, or when you need it to differ from
the literal request URI — set currentUrl in the config (or call $nav->setCurrentUrl('/pages') directly), and
it takes precedence over $_SERVER['REQUEST_URI']:
$config = [ 'on' => 'link-on', 'off' => 'link-off', 'currentUrl' => '/pages' ];
Using the same navigation tree from above, you can then create and render your nav object with the config:
use Pop\Nav\Nav; $nav = new Nav($tree, $config); echo $nav;
<nav id="main-nav"> <nav id="menu-1" class="item-1"> <a href="/users" class="link-off">Users</a> <nav id="nav-2" class="level-2"> <nav id="menu-2" class="item-2"> <a href="/users/roles" class="link-off">Roles</a> </nav> <nav id="menu-3" class="item-2"> <a href="/users/config" class="link-off">Config</a> </nav> </nav> </nav> <nav id="menu-4" class="item-1"> <a href="/orders" class="link-off">Orders</a> </nav> </nav>
The -1, -2, -3... suffixes on the generated id/class values above come from counters that increment
once per node processed across the entire tree, not per-branch — so the exact numbers depend on tree order
and will shift if you add or reorder nodes. See docs/POP-NAV.md for the full mechanics if you need to target
specific levels with CSS.
Using ACL
First, let's set up the ACL object with some roles and resources:
use Pop\Acl\Acl; use Pop\Acl\AclRole as Role; use Pop\Acl\AclResource as Resource; $acl = new Acl(); $admin = new Role('admin'); $editor = new Role('editor'); $acl->addRoles([$admin, $editor]); $acl->addResource(new Resource('config')); $acl->allow('admin'); $acl->deny('editor', 'config');
And then we add the ACL rules to the navigation tree:
$tree = [ [ 'name' => 'Home', 'href' => '/home', 'children' => [ [ 'name' => 'Users', 'href' => 'users' ], [ 'name' => 'Config', 'href' => 'config', 'acl' => [ 'resource' => 'config' ] ] ] ], [ 'name' => 'Orders', 'href' => '/orders' ] ];
Note: any node with an
aclkey requiressetAcl()to have been called on theNavobject first. If it hasn't, rendering throws aPop\Nav\Exception— even for a role that would ultimately have been denied anyway.
We then inject the ACL object into the navigation object, set the current role and render the navigation:
$nav = new Nav($tree); $nav->setAcl($acl); $nav->setRole($editor); echo $nav;
<nav> <nav> <a href="/home">Home</a> <nav> <nav> <a href="/home/users">Users</a> </nav> </nav> </nav> <nav> <a href="/orders">Orders</a> </nav> </nav>
Because the 'editor' role is denied access to the config page, that nav branch is not rendered. However,
if the role is set to $admin, the config branch renders:
$nav = new Nav($tree); $nav->setAcl($acl); $nav->setRole($admin); echo $nav;
<nav> <nav> <a href="/home">Home</a> <nav> <nav> <a href="/home/users">Users</a> </nav> <nav> <a href="/home/config">Config</a> </nav> </nav> </nav> <nav> <a href="/orders">Orders</a> </nav> </nav>
Default ACL evaluation is permissive. Unless you opt into strict mode,
pop-aclallows anything that isn't explicitly denied.configis hidden fromeditorabove only because it was explicitly denied — any otheracl-gated resource that nobody ever mentioned to theAclobject at all is visible to every role by default. For example, add a resource nobody has an explicit rule for:
$acl->addResource(new Resource('reports')); // No allow() or deny() call for 'reports' at all. $tree[0]['children'][] = [ 'name' => 'Reports', 'href' => 'reports', 'acl' => ['resource' => 'reports'] ]; $nav = new Nav($tree); $nav->setAcl($acl); $nav->setRole($editor); echo $nav; // 'Reports' renders - nothing denied it, so it's allowed by default
Call
$nav->setAclStrict(true)to flip this to "deny unless explicitly allowed" instead — with strict mode on, that same render would hideReports, becauseeditorhas no explicitallow()rule for it:
$nav = new Nav($tree); $nav->setAcl($acl); $nav->setRole($editor); $nav->setAclStrict(true); echo $nav; // 'Reports' is now hidden - nothing explicitly allowed it
setRole()/addRole() accumulate rather than replace, so a Nav can carry more than one role at once via
addRoles() — but this is not the "grant access if any role qualifies" union that RBAC systems usually mean
by "multiple roles." What it actually does depends on strict mode:
-
Default (non-strict) mode: a check fails the moment any currently-set role is explicitly denied — even if another role in the set would otherwise be allowed. Given the setup above, adding
$adminalongside$editordoes not restore access toconfig;editor's explicit denial still wins:$nav->setAcl($acl); $nav->addRoles([$editor, $admin]); // 'config' is still hidden - editor's deny wins echo $nav;
-
Strict mode: a check only passes if every currently-set role has its own explicit allow — so adding more roles can only narrow what's visible, never widen it, since each additional role must independently qualify.
If you want traditional "grant access if the user has any qualifying role" behavior, resolve the user's single
most-applicable role yourself before calling setRole(), or use a policy to express that
logic explicitly, rather than passing a user's whole role set to addRoles().
Using an ACL Policy
Beyond a plain resource/permission check, a node's ACL decision can be overridden by a policy — a callable that
resolves to the ACL role whose custom logic should decide access instead. Set it globally with config['policy'],
or per-node with acl.policy (which takes precedence over the global one for that node):
use Pop\Acl\AclRole as Role; use Pop\Acl\Policy\PolicyTrait; class OwnerPolicy extends Role { use PolicyTrait; // Method name must match the node's 'acl.permission' public function edit($role, $resource = null): bool { return $this->isOwnerOfCurrentPage(); // your own app-specific logic } }
$tree = [ [ 'name' => 'Edit Page', 'href' => 'edit', 'acl' => [ 'resource' => 'page', 'permission' => 'edit', 'policy' => fn() => new OwnerPolicy('owner'), ] ] ];
The policy callable's return value must be either the name of a role already registered on the Acl object, or
an object that uses Pop\Acl\Policy\PolicyTrait (as above) — pop-nav passes it straight to Acl::evaluatePolicy(),
which calls $role->can($permission, $resource). If the policy returns a non-null result, it replaces the normal
isAllowedMulti()/isAllowedMultiStrict() decision for that node.
Pop\Utils\CallableObject, a plain callable, and the [callable, ...args] array form are all accepted, matching
the other callable-accepting spots in the Pop PHP Framework.
Note: a node's ACL/policy is only evaluated once the render walks into it. If a node's
childrenare all denied by their own plain resource/permission checks, the wholechildrenbranch is skipped before any of their individualpolicyoverrides are evaluated — give at least one child an unconditional or already-allowed path if you need a policy-only child to be reachable. See docs/POP-NAV.md for the full mechanics.