Menus — side panel + top menu navigation
OrangeHRM's navigation chrome (the dark side panel + the top horizontal menu inside each section) is server-computed per request, rendered by Vue from JSON injected via Twig. Menu items live in ohrm_menu_item and are visibility-gated by the user's role-screen permissions.
This skill covers: the entity model, how menus get computed and rendered, the MenuConfigurator extension hook, and the standard pattern for adding a menu entry when shipping a new page.
The two-level structure
┌──────────────────────────────────────────────┐
│ Side panel │ Top menu within selected section │
├───────────────┼──────────────────────────────┤
│ Admin ◉ │ Configuration ▾ Users Job ▾ Organization ▾ … │ ← top menu
│ PIM │ │
│ Leave │ [page content here] │
│ Time │ │
│ Recruitment │ │
│ … │ │
└───────────────┴──────────────────────────────┘
↑ level 1 ↑ level 2 + children
| Layer | Stored as | Renders as |
|---|---|---|
| Side panel | MenuItem rows with level=1 and parent_id=NULL |
The vertical icon-list on the left (PIM, Leave, Time, etc.) |
| Top menu | MenuItem rows with level=2 under a selected side-panel item |
The horizontal menu bar at the top of the section (within Admin: User Management, Job, Organization, Qualifications, …) |
| Sub-menu (level 3+) | MenuItem rows with level=3, parented to a top menu item |
The dropdown items under a top-menu hover |
Only side panel + top menu are typically active at once; the side panel determines the section, and the top menu shows the screens of that section.
The MenuItem entity
@ORM\Table(name="ohrm_menu_item")
class MenuItem
{
private int $id;
private string $menuTitle; // shown to the user (translatable via $t)
private ?MenuItem $parent = null; // null = top-level (side panel)
private int $level; // 1 = side panel, 2 = top menu, 3+ = nested
private int $orderHint; // sort order within siblings
private bool $status; // false = hidden (e.g. when a module is disabled)
private ?Screen $screen = null; // the destination page (or null = group only)
private ?array $additionalParams = []; // JSON blob; usually {icon: 'pim', class: '...'}
}
Six fields shape the menu:
menuTitle— display string. Not translated in the DB — the value matches a lang-string key (or sometimes the literal English fallback). The Vue side runs it through$t()to translate (seefrontend-platform).parent— self-FK. Root items haveparent=NULL. Children point at their parent'sid.level—1(side panel),2(top menu),3(top menu dropdown items). Defined by where in the tree the item sits; redundantly stored so queries don't have to walk the tree.orderHint— sort key within siblings. Lower number = appears first.status— boolean toggle.falsehides the item without deleting the row. Used when a module is disabled (seeenableModuleMenuItems()below).screen— FK toohrm_screen. Two purposes:- Destination: clicking the menu item navigates to
Screen.action_urlwithinScreen.module_id. - Permission gate: the user must have
can_readon thisohrm_user_role_screenrow to see the menu item (seeauthorizationskill). If they lack it, the menu service filters this item out. - A menu item with
screen=NULLis a group only — it appears as a label, may have children, but can't be clicked to navigate.
- Destination: clicking the menu item navigates to
additionalParams—jsoncolumn. Typically{"icon": "user", "class": "admin-icon"}for side-panel items, or extras specific to the rendering. The OXD layout reads these in the Vue side.
How menu rendering works
End-to-end flow on every request that hits an AbstractVueController:
1. Page controller's preRender() runs (see frontend-pages skill)
↓
2. AbstractVueController calls VueControllerHelper::getContextParams()
↓
3. VueControllerHelper calls MenuService::getMenuItems(baseUrl)
↓
4. MenuService:
- Looks up the current Screen from URL (via ModuleScreenHelper)
- If the Screen has a MenuConfigurator, instantiates and calls configure()
→ may return a MenuItem chain that overrides active-detection
→ may override which Screen is considered "current" via overrideScreen()
- Loads all level-1 (side panel) items + caches result
- Walks each to determine the active side-panel item
- For the active side-panel, loads its level-2 (top menu) items + caches
- Walks each top-menu item to determine which is active
- Filters out items the user lacks screen permission for
- Filters out items where status=false (disabled modules)
- Normalizes each into a JSON-friendly dict
↓
5. Returns [sidePanelMenuItems, topMenuItems] arrays
↓
6. Twig template renders:
<oxd-layout
:sidepanel-menu-items="{{ sidePanelMenuItems | json_encode() }}"
:topbar-menu-items="{{ topMenuItems | json_encode() }}"
…
/>
↓
7. Vue's <oxd-layout> renders the menu chrome using those props
Two takeaways:
- All menu logic is server-side. The frontend (
<oxd-layout>) is a dumb renderer — it gets JSON, displays it. There is no client-side menu construction. - Visibility = permission AND status. A menu item shows only if (a) the user's effective roles include
can_readon the linked screen (seeauthorization), and (b) the item'sstatus=true(the module isn't disabled).
The cache layer
MenuService caches the detailed side panel menu items and top menu items per side-panel in the OHRM cache (CacheTrait — see helpers):
// MenuService internals
private function getDetailedSidePanelMenuItemsAlongWithCache(): array
{
return $this->getCache()->get('detailed_side_panel_menu_items', /* compute callback */);
}
private function getTopMenuItemsAlongWithCache(int $sidePanelMenuItemId): array
{
$key = "top_menu_items.{$sidePanelMenuItemId}";
return $this->getCache()->get($key, /* compute callback */);
}
Implications:
- Menu structure is cached globally, not per user. Permission filtering happens after the cached fetch, on the filtered list per-request.
- After a migration that adds/removes/renames menu items:
MenuService::invalidateCachedMenuItems()must run. Orbin/console cache:clear(seeconsole-commands). - Adding a menu item without invalidating the cache means existing prod instances won't show it until the next cache wipe.
enableModuleMenuItems(string $moduleName, array $menuTitles = []) is the canonical "enable / disable module's menu items" call. Used by the Dashboard subscribers when a module's status toggles (see events). It updates status on the relevant rows and invalidates the cache.
MenuConfigurator — the runtime extension hook
namespace OrangeHRM\Core\Menu;
interface MenuConfigurator
{
public function configure(Screen $screen): ?MenuItem;
}
Per-screen runtime customization. Stored on Screen.menu_configurator as the FQCN string of an implementing class. When MenuService::getMenuItems() resolves the current screen, if a configurator is set, it's instantiated and called.
The configurator returns either:
- A
MenuItem— the menu service walks its parent chain and uses that as the "active menu" highlight (overrides the normal active-detection). Used when a page should highlight a different menu entry than the screen-based default. null— the configurator only needed to side-effect (e.g. callgetCurrentModuleAndScreen()->overrideScreen('viewJobTitleList')to make the active-menu computation use a different screen).
Examples in the codebase (*/Menu/*MenuConfigurator.php):
JobTitleMenuConfigurator— on the "Add Job Title" screen, override the current screen toviewJobTitleListso the list-page menu item highlights (otherwise "Add" would have no menu entry).PIMLeftMenuItemConfigurator— on an Employee detail screen, if the viewing user is themselves, highlight "My Info" in the side panel; otherwise treat it as the employee list view.PayGradeConfigurator,LocationMenuConfigurator, etc. — same "override active" pattern for various entity-add/edit screens.
When to write one:
- A "create" or "edit" page where the matching menu item is the parent list ("Add User" should highlight "User Management → Users")
- A polymorphic page that should highlight different menu items based on who's viewing or what they're viewing
- Anything where the menu item to highlight isn't directly the screen the user is on
When not to:
- The screen has its own menu entry → no configurator needed, default active-detection works
- You want to globally hide a menu item → that's
statusor permissions, not a configurator
Configurator template
namespace OrangeHRM\X\Menu;
use OrangeHRM\Core\Menu\MenuConfigurator;
use OrangeHRM\Core\Traits\ModuleScreenHelperTrait;
use OrangeHRM\Core\Traits\Service\MenuServiceTrait;
use OrangeHRM\Entity\MenuItem;
use OrangeHRM\Entity\Screen;
class WidgetEditMenuConfigurator implements MenuConfigurator
{
use ModuleScreenHelperTrait;
use MenuServiceTrait;
public function configure(Screen $screen): ?MenuItem
{
// Option A: pretend we're on a different screen for active-menu purposes
$this->getCurrentModuleAndScreen()->overrideScreen('viewWidgetList');
return null;
}
}
Or to point at a specific menu item directly:
public function configure(Screen $screen): ?MenuItem
{
return $this->getMenuService()->getMenuDao()->getMenuItemByTitle('Widget Management', 1);
}
Configurators can compose any traits (auth user, request, services) and make decisions based on context — see PIMLeftMenuItemConfigurator which checks $this->getAuthUser()->getEmpNumber() against a URL param.
Permission gating
Menu items linked to a Screen are filtered by the user's role-screen permissions (the ohrm_user_role_screen table — see authorization skill). The filter happens in MenuService after the cache fetch:
For each menu item:
if (item->screen != null)
if (no row in ohrm_user_role_screen for user's effective roles × this screen with can_read=true)
filter out
Group-only items (screen=NULL) aren't permission-gated directly — they show if any of their children show. An empty group is filtered out.
This means: a permission seed without a matching menu item gives the user access to the screen via URL but no menu entry to reach it. Conversely, a menu item without a permission seed shows for nobody and is dead. Always seed both together (see authorization skill for permission seeding).
additionalParams — the JSON extras
The column carries layout-specific extras the Vue layer wants:
{
"icon": {"name": "pim"},
"class": "menu-pim"
}
The OXD <oxd-layout> component reads icon to render the side-panel icon, and may use class for styling. Exact shape depends on which OXD layout version you're on — to know what's supported, check existing menu rows that work and mirror them.
For new side-panel items, the most important extra is icon — pick from the OXD icon set (the icons used by existing items: user, dashboard, pim, time, leave, etc.).
For top-menu and child items, additionalParams is usually empty — they don't need icons or extra styling beyond the title.
Seeding via migrations
Menu items are seeded by migrations (see migrations skill), inserted into ohrm_menu_item. There's no YAML helper specifically for this; use createQueryBuilder()->insert() directly:
// Inside a migration's up()
public function up(): void
{
// First create/find the parent (e.g. the "Admin" side panel item)
$parentId = $this->createQueryBuilder()
->select('id')
->from('ohrm_menu_item')
->where('menu_title = :title AND level = 1')
->setParameter('title', 'Admin')
->fetchOne();
// Get the screen this menu item links to
$screenId = $this->createQueryBuilder()
->select('id')
->from('ohrm_screen')
->where('action_url = :url')
->setParameter('url', 'viewWidgetList')
->fetchOne();
// Insert the menu item
$this->createQueryBuilder()
->insert('ohrm_menu_item')
->values([
'menu_title' => ':title',
'screen_id' => ':screenId',
'parent_id' => ':parentId',
'level' => ':level',
'order_hint' => ':order',
'status' => ':status',
])
->setParameter('title', 'Widgets')
->setParameter('screenId', $screenId)
->setParameter('parentId', $parentId)
->setParameter('level', 2) // top menu within Admin
->setParameter('order', 500) // adjust to slot it where you want
->setParameter('status', true)
->executeStatement();
// Invalidate the cache so existing instances see the new item
// (caches don't persist across migration runs typically, but defensive)
}
Always seed in this order:
- The screen first (
ohrm_screen) — seeauthorizationskill - The screen permissions (
ohrm_user_role_screen) — seeauthorizationskill - The menu item (
ohrm_menu_item) — this skill
The migration is one atomic operation; failures roll back. Don't add the menu item before the screen exists or you'll have a dangling FK.
When a module is disabled
OrangeHRM has module-level on/off (ohrm_module.status). When a module gets disabled:
- The
ModuleStatusChangeevent fires (seeeventsskill) - Plugin-specific subscribers (e.g.
BuzzModuleStatusChangeSubscriber,TimeModuleStatusChangeSubscriber,LeaveModuleStatusChangeSubscriberin Dashboard) callMenuService::enableModuleMenuItems($moduleName, [])with empty titles to flip every related menu item'sstatusto false - The cache is invalidated
- On the next request, the menu items are gone
enableModuleMenuItems(string $moduleName, array $menuTitles = []):
$menuTitlesempty = disable all$menuTitlesnon-empty = enable only those specific titles, disable the rest
You don't typically call this manually — the module status-change subscriber handles it. But it's why a menu item suddenly disappearing usually correlates with a module toggle.
Recipes
Recipe 1 — Add a menu entry alongside a new page
Assuming the new page (Widget List) already has:
- A Vue component registered (
frontend-pagesskill) - A backend page controller (
authorizationskill) - A
Screenrow seeded withaction_url='viewWidgetList',module=admin - A
ScreenPermissionrow for Admin role withcan_read=true
Now add the menu item:
// In a migration's up()
$screenId = $this->createQueryBuilder()
->select('id')->from('ohrm_screen')
->where('action_url = :url')->setParameter('url', 'viewWidgetList')
->fetchOne();
$parentId = $this->createQueryBuilder()
->select('id')->from('ohrm_menu_item')
->where('menu_title = :title AND level = 1')
->setParameter('title', 'Admin')
->fetchOne();
$this->createQueryBuilder()
->insert('ohrm_menu_item')
->values([
'menu_title' => ':title',
'screen_id' => ':screenId',
'parent_id' => ':parentId',
'level' => ':level',
'order_hint' => ':order',
'status' => ':status',
])
->setParameter('title', 'Widgets')
->setParameter('screenId', $screenId)
->setParameter('parentId', $parentId)
->setParameter('level', 2)
->setParameter('order', 600)
->setParameter('status', true)
->executeStatement();
Existing instances need bin/console cache:clear (or wait for the cache to expire) before the new menu item appears.
Recipe 2 — Add a side-panel section for a new module
A new top-level section (level 1) gets its own icon:
$this->createQueryBuilder()
->insert('ohrm_menu_item')
->values([
'menu_title' => ':title',
'screen_id' => ':screenId',
'parent_id' => ':parentId', // NULL for side panel
'level' => ':level',
'order_hint' => ':order',
'status' => ':status',
'additional_params' => ':params',
])
->setParameter('title', 'X')
->setParameter('screenId', $defaultLandingScreenId) // first page in section
->setParameter('parentId', null)
->setParameter('level', 1)
->setParameter('order', 800) // after existing sections
->setParameter('status', true)
->setParameter('params', json_encode(['icon' => ['name' => 'x-icon']]))
->executeStatement();
The icon value must match an OXD-supported icon name. Look at existing side-panel item rows to see what's available.
After adding the side panel section, also add level-2 (top menu) items underneath it — same pattern, with parent_id set to the side panel's id.
Recipe 3 — A MenuConfigurator for an "edit" screen
// src/plugins/orangehrmXPlugin/Menu/EditWidgetMenuConfigurator.php
namespace OrangeHRM\X\Menu;
use OrangeHRM\Core\Menu\MenuConfigurator;
use OrangeHRM\Core\Traits\ModuleScreenHelperTrait;
use OrangeHRM\Entity\MenuItem;
use OrangeHRM\Entity\Screen;
class EditWidgetMenuConfigurator implements MenuConfigurator
{
use ModuleScreenHelperTrait;
public function configure(Screen $screen): ?MenuItem
{
// On the edit page, highlight "Widgets" (the list page's menu entry)
$this->getCurrentModuleAndScreen()->overrideScreen('viewWidgetList');
return null;
}
}
Then point the screen at this configurator in the migration:
// Add the editWidget screen with menu_configurator set
$this->createQueryBuilder()
->insert('ohrm_screen')
->values([
'name' => ':name',
'module_id' => ':moduleId',
'action_url' => ':url',
'menu_configurator' => ':configurator',
])
->setParameter('name', 'Edit Widget')
->setParameter('moduleId', $adminModuleId)
->setParameter('url', 'editWidget')
->setParameter('configurator', 'OrangeHRM\X\Menu\EditWidgetMenuConfigurator')
->executeStatement();
If you're using the permission/screens.yaml helper from authorization skill, set menu_configurator there:
editWidget:
name: 'Edit Widget'
module: admin
url: editWidget
menu_configurator: OrangeHRM\X\Menu\EditWidgetMenuConfigurator
permissions:
- { role: Admin, permission: { read: true, create: false, update: true, delete: false } }
Now when the user is on /admin/editWidget, the "Widgets" menu item highlights instead of nothing.
Recipe 4 — A polymorphic configurator (different highlight based on context)
class WidgetDetailConfigurator implements MenuConfigurator
{
use AuthUserTrait;
use ControllerTrait;
use MenuServiceTrait;
public function configure(Screen $screen): ?MenuItem
{
$widgetId = $this->getCurrentRequest()->attributes->get('widgetId');
$isMine = $this->getAuthUser()->ownsWidget($widgetId);
if ($isMine) {
return $this->getMenuService()->getMenuDao()
->getMenuItemByTitle('My Widgets', 2);
}
return $this->getMenuService()->getMenuDao()
->getMenuItemByTitle('All Widgets', 2);
}
}
Pattern from PIMLeftMenuItemConfigurator. Useful when one screen serves multiple distinct user-flows and the right menu highlight depends on which flow.
Recipe 5 — Disable a menu item without removing the screen
$this->createQueryBuilder()
->update('ohrm_menu_item')
->set('status', ':status')
->where('menu_title = :title AND level = 2')
->setParameter('status', false)
->setParameter('title', 'Deprecated Section')
->executeStatement();
The screen still works via direct URL; the menu just doesn't show it. Reverse by setting status = true. Cache clear required for the change to take effect on existing prod instances.
Checklists
Add a menu entry for a new page
- Backend page controller exists and renders the Vue component (see
frontend-pagesskill) -
Screenrow exists inohrm_screenwith the action URL (seed viapermission/screens.yamlin a migration — seeauthorizationskill) -
ohrm_user_role_screenrows seeded so target roles can read the screen - Find the parent
MenuItem's id (the section side-panel item, or the level-2 top menu item if your screen is a level-3 sub-item) - Insert a row in
ohrm_menu_itemwith:menu_title,screen_id,parent_id,level,order_hint,status=true, optionallyadditional_params - After deploy:
bin/console cache:clear(or wait for cache TTL) so the menu service picks up the new item - Verify in browser: log in as the targeted role and confirm the menu item appears + clicks to the right page
Write a MenuConfigurator
- Class in
src/plugins/orangehrm{X}Plugin/Menu/<Name>MenuConfigurator.phpimplementingMenuConfigurator -
configure(Screen $screen): ?MenuItemreturns the menu item to highlight (ornulland calloverrideScreen()to make the default active-detection match a different screen) - Compose
ModuleScreenHelperTraitforgetCurrentModuleAndScreen() - Compose
MenuServiceTraitif returning aMenuItemdirectly - Compose
AuthUserTrait+ControllerTraitif behavior depends on user/request context - Set
menu_configuratorto the configurator's FQCN on the relevantScreenrow (viapermission/screens.yamlor migration) - No registration step —
MenuServicelooks up the configurator FQCN per request
Debug "my new menu item doesn't appear"
- Is the row in
ohrm_menu_item? Check it directly:SELECT * FROM ohrm_menu_item WHERE menu_title = '…'. Missing = migration didn't run, or referenced wrong parent. - Is
status=true? Toggle in DB to verify. - Has the cache been invalidated?
bin/console cache:clearand refresh. Stale cache is the most common cause in prod. - Does the user have screen permission?
SELECT * FROM ohrm_user_role_screen WHERE screen_id = … AND user_role_id IN (…). No row = item filtered out. - Is
levelright? A level-2 row with no level-1 parent ID won't appear; a level-1 row with a parent ID won't either. - Is the parent's
statustrue? If the parent side-panel section is disabled, all children are hidden too. - Is the module status enabled?
SELECT status FROM ohrm_module WHERE name = '…'. A disabled module means theModuleStatusChangeevent has flipped its menu items'statusto false.
Add a MenuConfigurator for an existing screen
- Configurator class implementing
MenuConfigurator - Migration updates the
ohrm_screenrow:UPDATE ohrm_screen SET menu_configurator = 'FQCN' WHERE action_url = '...' - Cache clear after deploy
- Verify: navigate to the screen, confirm the intended menu item is highlighted instead of nothing
Things that bite
- Cache invalidation is mandatory after menu changes.
MenuServicecaches side-panel + per-section top-menu lists in the OHRM cache. Adding a row inohrm_menu_itemand not runningbin/console cache:clearmeans prod instances keep the old menu indefinitely. Migrations that add menu items should make this clear in their commit message. - Menu items linked to a non-existent screen are silently filtered out. A migration that inserts a menu row before the corresponding screen row creates a dead menu entry. Always seed: Screen → ScreenPermission → MenuItem.
- Permission filter happens after cache fetch. That's why caching is safe across users — the cache holds the structure, the per-request filter applies permissions. But this also means an over-broad cache (e.g., one cached per-user) wouldn't reflect permission changes until clear. Don't try to "improve" the caching to per-user.
menuTitleis the lang-string key, not the translated text. The Vue layer translates via$t($item.title). Migrations that seed a literal English string like'Widgets'need a matchinggeneral.widgets(or whatever group) lang-string entry — seefrontend-platform. Untranslated keys render as the literal key string to the user.MenuConfiguratorFQCN is stored as a string inohrm_screen.menu_configurator. Renaming the class breaks the screen until a migration updates the column. Refactor with care.- A
MenuConfiguratorreturningnullis fine as long as it side-effects (e.g.overrideScreen()). Anullreturn WITHOUT a side effect just means "I had nothing to say" and the default active-detection runs. - Level is denormalized — it duplicates info derivable from the
parentchain. Inconsistentlevelvalues (e.g. level=2 but parent is level=2 not level=1) confuse the menu service and may produce hidden items. additional_paramsis a JSON column. Don't write strings there — Doctrine will reject or double-encode. The migrationINSERTusesjson_encode([...]).- The Dashboard plugin's module-status-change subscribers flip menu items'
statuswhen their owning module toggles. A "missing menu items after enabling/disabling a module" issue is usually one of those subscribers firing — checkBuzzModuleStatusChangeSubscriber,TimeModuleStatusChangeSubscriber, etc.