Menu::getItems() static cache is keyed on neither user nor area — admin sidebar silently loses items; cross-user leak under persistent runtimes
Bug report
Issue Description
Webkul\Core\Menu::getItems() memoises its result into a static $items local. The cache is keyed on nothing — not the $area argument, not the authenticated user — so the first caller in a PHP process fixes the menu for every later caller in that process.
Two consequences:
- An administrator's sidebar silently loses items. If any earlier call in the same process resolved the menu for a lower-privileged user, the admin gets that user's menu. This may explain intermittent "menu items missing" reports that don't reproduce on demand — it depends entirely on call ordering within a process.
getItems('customer')aftergetItems('admin')returns the admin menu. The$areaargument is ignored once the cache is warm, even though the method throws when$areais absent.
Because it is keyed on neither dimension, this looks like an oversight rather than a deliberate trade-off — which is why the suggested fix below is just "key it properly" rather than a redesign.
Under PHP-FPM the impact is limited: one request per process, so the cache lives exactly one request, and the ordering problem only surfaces where a single request resolves the menu for more than one user. Under a persistent runtime — Laravel Octane, Swoole, RoadRunner, or a long-lived queue worker rendering mail — the cache survives across requests per worker, and the same code becomes a cross-user menu leak until the worker recycles.
This is not a security vulnerability and I'm not reporting it as one. Route authorisation is enforced separately by Admin\Http\Middleware\Bouncer through acl()->getRoles(), and it is unaffected — a user who sees a leaked menu entry still receives a 401 on the route. This is presentation-layer only: the sidebar shows entries the viewer cannot use, or hides entries they can.
Preconditions
1. Laravel 12 / PHP 8.3 / MySQL 8.0 — Krayin v2.2.0 (Webkul\Core\Core::KRAYIN_VERSION)
2. Commit id: de2c37a6Steps to reproduce
Does not require Octane. Any single PHP process that resolves the menu for two users shows it, so plain artisan is enough — the mechanism is identical to what an Octane worker exhibits across requests.
Given two users: an admin on a role with permission_type = all, and a staff user on a custom role that does not hold the leads permission.
1. Resolve the menu as the staff user
2. Resolve the menu as the admin, in the same process
3. Compare — and then repeat with the order reversed$admin = User::find(1);
$staff = User::where('email', '[email protected]')->first();
auth()->guard('user')->setUser($staff);
$staffKeys = collect(menu()->getItems('admin'))->map(fn ($i) => $i->getKey());
auth()->guard('user')->setUser($admin);
$adminKeys = collect(menu()->getItems('admin'))->map(fn ($i) => $i->getKey());The offending lines, packages/Webkul/Core/src/Menu.php:
public function getItems(?string $area = null, string $key = ''): Collection
{
if (! $area) {
throw new \Exception('Area must be provided to get menu items.');
}
static $items;
if ($items) {
return $items; // first caller's menu, returned to everyone
}
// ... per-user filter below is correct; the cache around it is not
$this->configMenu = $configMenu
->filter(fn ($item) => bouncer()->hasPermission($item['key']))
->toArray();The bouncer()->hasPermission() filter itself is correct and per-user. It simply only runs once per process.
Expected result
Each call reflects the currently authenticated user and the requested area:
- staff →
dashboard, mail, activities, contacts, configuration - admin →
dashboard, leads, quotes, mail, activities, contacts, products, settings, configuration
Actual result
Both calls return whichever menu was built first.
Staff first, then admin — the admin's sidebar is truncated to the staff menu:
1) as STAFF -> dashboard, mail, activities, contacts, configuration
2) as ADMIN -> dashboard, mail, activities, contacts, configurationReversed, admin first, then staff — the staff user receives the admin sidebar including leads, while bouncer()->hasPermission('leads') correctly returns false for them:
1) as ADMIN -> dashboard, leads, quotes, mail, activities, contacts, products, settings, configuration
2) as STAFF -> dashboard, leads, quotes, mail, activities, contacts, products, settings, configurationIt also affects test suites: any Pest/PHPUnit run where one test authenticates and renders a page, then another asserts on a different user's menu, receives the first user's menu. A correct menu-visibility test in our suite passed in isolation and failed as soon as it ran alongside another test.
Suggested fix
Not opening a PR unsolicited — glad to if it would help, and equally glad to be told the current behaviour is intended. Options, cheapest first:
- Key the cache by area and user.
static $items = []indexed on$area.':'.(auth()->guard('user')->id() ?? 'guest'). Smallest change that fixes both the user and the$areaproblem, and keeps the optimisation. - Drop the memoisation.
getItems()does aconfig()read plus a filter and is called a handful of times per request; the cache may not be buying much. - Move it off
staticonto the singleton, plus aflush()method, so Octane'sRequestReceivedlistener can clear it — the pattern Octane documents for stateful singletons.
Workaround
Assert on bouncer()->hasPermission($key) rather than menu()->getItems() — it is the exact predicate getItems() filters each entry on, and carries no cache.
Source: krayin/laravel-crm