Most SaaS navigation tutorials show you how to render a <nav> with 5 links. Real multi-tenant apps need something very different: menus that change based on who's logged in, what company they're in, and what permissions they have.
I built a complete navigation module for Kohana.io - a production CRM/ERP - and I'm now extracting it into LaraFoundry, an open-source SaaS framework for Laravel.
This post covers the full system: backend menu building, permission filtering, sub-menu routing, the First Allowed Route pattern, frontend rendering (desktop + mobile), and testing.
The Problem
In a multi-tenant SaaS with role-based access, navigation has to solve several problems at once:
- Admins see a completely different menu than regular users (admin panel vs business modules)
- Company owners see all modules (Orders, Warehouse, Accounting, Production...)
- Employees see only modules they have permissions for
- Each module has sub-pages with potentially different permission requirements
- When a user switches companies, the menu must recalculate
- The same data must render on desktop (two-tier header) and mobile (hamburger with collapsible sections)
- Users should be able to set a default landing page
- There should be zero 403 pages - if a user hits a forbidden page, redirect them gracefully
Architecture Overview
Request
|
v
LayoutController::getLayout()
|
v
LayoutDataService
|
├── getNav('upLevel') → Desktop top-level tabs
├── getNav('bottomLevel') → Desktop sub-page tabs
├── getNav('myCompanySidebar') → Company management sidebar
├── getNav('mobile') → Full mobile tree
├── getFirstAllowedRouteName() → Smart redirect target
|
└── Each method calls:
getNavItems($items, $level)
└── checkUserAndCompanyPolicy($policyName)
├── Admin → always allowed
├── 'public' → any authenticated user
└── else → $user->hasPermissionTo($policy, $company)
|
v
HandleInertiaRequests (middleware)
|── Shares layout data as Inertia prop (lazy-loaded)
|
v
Vue frontend
├── Desktop: two-tier header
└── Mobile: hamburger pullout menu
Backend: LayoutDataService
The entire navigation system lives in one service class - LayoutDataService. It handles four distinct navigation contexts from a single source of truth.
Menu Item Structure
Every menu item follows the same shape:
[
'linkName' => __('Orders'), // Translated label
'linkUrl' => route('orders.incoming'), // Target URL
'policyName' => 'orders.view', // Permission slug
'routeName' => 'orders', // Route name for matching
'linkIconSvg' => 'icon_orders.svg', // Icon file
'isLinkActive' => Route::currentRouteNamed('orders.*'),
'companyItem' => true, // Company-specific item
]
Permission Filtering
The core of the system is checkUserAndCompanyPolicy():
private function checkUserAndCompanyPolicy($policyName): bool
{
// Admins see everything
if (self::isAdmin()) return true;
// Empty policy = blocked (security measure)
if (empty($policyName)) return false;
// Public items (notifications, support, profile)
if ($policyName === 'public') return auth()->check();
// Company-specific permission check
$user = auth()->user();
$company = $user->activeCompany;
if ($user->isOwnerOf($company)) return true;
return $user->hasPermissionTo($policyName, $company);
}
This one method handles all three user types. No separate admin menu builder. No role-checking conditionals scattered across the codebase.
Three User Types, Three Menus
| User type | Menu | Modules |
|---|---|---|
| Admin | Admin panel | Users, Companies, Payments, Notifications, Support |
| Owner | Business modules | All 8 modules + company management sidebar |
| Employee | Filtered business modules | Only modules with explicit permissions |
The key design decision: owners always see all modules, even if their subscription expired. Subscription blocking is handled at the page level with a separate overlay, not by hiding navigation. This way users know what they're paying for.
Sub-Menu Routing
Clicking "Orders" doesn't go to /orders. It goes to the user's default sub-page for that module.
// GetDefaultBottomLevelRouteNameAction
'orders' => 'orders.incoming',
'warehouse' => 'warehouse.products',
'accounting' => 'accounting.funds_movement',
'contragents' => 'contragents.customers',
'production' => 'production.main_workshop',
Sub-routes also have their own permissions:
// Warehouse sub-routes
[
['routeName' => 'warehouse.report', 'policyName' => 'warehouse.view'],
['routeName' => 'warehouse.products', 'policyName' => 'warehouse.view'],
['routeName' => 'warehouse.consumables', 'policyName' => 'warehouse.view'],
['routeName' => 'warehouse.settings', 'policyName' => 'warehouse.manageCategories'],
]
An employee with warehouse.view but without warehouse.manageCategories will see Products, Consumables, and Report tabs, but not Settings.
The First Allowed Route (FAR) Pattern
This pattern eliminates 403 pages entirely. I covered it in detail in my multi-tenancy series, but here's the core:
public function getFirstAllowedRouteName(): string
{
if (self::isAdmin()) return 'admin.dashboard.index';
if (!$this->authUserData->hasUserCompanyOrRole()) {
return 'notifications.index';
}
// Check user's saved default route
$saved = auth()->user()->getDefaultRouteForActiveCompany();
if ($saved && $this->isBottomLevelRouteAccessible($saved)) {
return $saved;
}
// Walk the menu, find first accessible page
foreach ($this->getNavUpLevelItemsArray() as $item) {
if (!empty($item['unvisibly'])) continue;
if ($this->checkUserAndCompanyPolicy($item['policyName'])) {
$route = $this->getFirstAllowedBottomLevelRoute($item['routeName']);
return $route ?? $item['routeName'];
}
}
return 'notifications.index';
}
This handles four critical scenarios:
- After login - where should the user land?
- After 403 - redirect instead of showing error page
- After company switch - permissions may differ
- Home link - always points to an accessible page
The 403 exception handler ties it all together:
// bootstrap/app.php
$exceptions->renderable(function (AccessDeniedHttpException $e, Request $request) {
$layoutService = app(LayoutDataService::class);
return redirect()
->route($layoutService->getFirstAllowedRouteName())
->with('message-disappear-error', __('You do not have permission to access this page'));
});
User-Configurable Default Page
Users can set their preferred landing page in profile settings. It's stored per company in the company_user pivot table. If permissions change and that page becomes inaccessible, the system clears the default automatically and shows a warning.
Data Flow to Frontend
LayoutController::getLayout() returns everything the frontend needs:
[
'layoutData' => [
'homeRoute' => $this->getFirstAllowedRoute(),
'navDesktopUpLevel' => $this->getNav('upLevel'),
'navDesktopBottomLevel' => $this->getNav('bottomLevel'),
'navDesktopMycompanySidebar' => $this->getNav('myCompanySidebar'),
'navMobile' => $this->getNav('mobile'),
'defaultRoute' => $savedDefaultRoute,
'breadcrumbsString' => $breadcrumbs,
],
'authUserData' => [
'currentCompanyName' => $company->name,
'currentRole' => $role->slug,
'isAuthUserOwnerOfCurrentCompany' => $isOwner,
// ... 15+ more fields
]
]
This is shared via HandleInertiaRequests middleware as a lazy-loaded prop. One request, all navigation data included. Zero extra API calls.
Frontend: Desktop Navigation
The desktop layout uses a two-tier header:
<template>
<!-- Top: module tabs -->
<nav class="header-nav">
<Link v-for="item in layoutData.navDesktopUpLevel"
:key="item.routeName"
:href="item.linkUrl"
:class="{ 'header-nav__link--active': item.linkActive }">
{{ item.linkName }}
</Link>
</nav>
<!-- Bottom: sub-pages for current module -->
<nav class="header-nav-bottom">
<Link v-for="item in layoutData.navDesktopBottomLevel"
:key="item.routeName"
:href="item.linkUrl"
:class="{ 'header-nav__link--active': item.linkActive }">
{{ item.linkName }}
</Link>
</nav>
</template>
Active states are computed server-side via Route::currentRouteNamed(). The frontend doesn't need to match URLs or check permissions - it's all in the data.
Frontend: Mobile Navigation
Mobile gets a completely different data structure. navMobile is a hierarchical tree:
[
{
linkName: "Orders",
linkUrl: "/orders/incoming",
linkActive: true,
linkIconUrl: "/icons/icon_orders.svg",
child: [
{ linkName: "Report", linkUrl: "/orders/report", linkActive: false },
{ linkName: "Incoming", linkUrl: "/orders/incoming", linkActive: true },
{ linkName: "Outgoing", linkUrl: "/orders/outgoing", linkActive: false },
]
}
]
The mobile menu is a slide-out pullout from the left, built with CSS transitions and GPU acceleration:
.pullout-menu__left {
will-change: transform;
transform: translateZ(0);
backface-visibility: hidden;
transition: transform 0.3s ease;
}
It has a settings mode toggle where users can select their default landing page via radio buttons. This fires a PUT request to the backend.
Responsive strategy: Mobile-first with Custom SCSS breakpoints:
<div class="lg:hidden"><!-- Mobile menu --></div>
<div class="hidden lg:flex"><!-- Desktop menu --></div>
Right-Side Pullout
A separate right pullout handles user profile, company info, language selection, and quick actions. On mobile it slides from the right. On ultra-wide screens (2xl+) it drops from the top.
State Management
No Vuex. No Pinia. Navigation state is managed with reactive refs in the layout component:
const viewPulloutMobilemenu = ref(false);
const viewPulloutMenuRight = ref(false);
const viewOverlay = ref(false);
Child components communicate via emits. Overlays use CSS transitions with JS cleanup timeouts.
Testing
Navigation is authorization. If the menu is wrong, security is wrong.
it('shows all modules to company owner', function () {
$owner = User::factory()->owner()->create();
actingAs($owner)
->get('/dashboard')
->assertInertia(fn ($page) =>
$page->has('layout.layoutData.navDesktopUpLevel', 8)
);
});
it('hides modules without permission from employee', function () {
$employee = User::factory()->employee()->create();
grantPermission($employee, 'orders.view');
actingAs($employee)
->get('/orders/incoming')
->assertInertia(fn ($page) =>
$page->where('layout.layoutData.navDesktopUpLevel', fn ($nav) =>
collect($nav)->pluck('routeName')->contains('orders')
&& !collect($nav)->pluck('routeName')->contains('warehouse')
)
);
});
it('redirects to first allowed route when default is revoked', function () {
$employee = User::factory()->employee()->create();
$employee->setDefaultRoute('warehouse.products');
revokePermission($employee, 'warehouse.view');
actingAs($employee)
->get('/')
->assertRedirect(route('orders.incoming'));
});
Test coverage includes:
- Admin menu isolation
- Owner sees all 8 modules
- Employee sees only permitted modules
- Sub-level permission filtering (warehouse.view vs warehouse.manageCategories)
- Default route save/clear/fallback
- Company switch recalculation
- Mobile menu tree consistency
- Hidden items (notifications, support) remain accessible via URL
What's Included
| Feature | Details |
|---|---|
| Menu building | Dynamic per-request via LayoutDataService |
| User types | Admin, Owner, Employee - distinct menus |
| Permissions | Module-level + sub-page-level filtering |
| FAR pattern | Zero 403 pages, smart redirects |
| Default pages | User-configurable per company |
| Desktop | Two-tier header (modules + sub-pages) |
| Mobile | Hamburger with collapsible parent/child |
| Transitions | GPU-accelerated CSS (will-change, translateZ) |
| i18n | All labels translated via __() |
| Testing | Pest tests for all user types and edge cases |
Key Takeaway
Push complexity to the backend. The frontend should render data, not compute it. LaraFoundry's Vue components don't check permissions, don't calculate active states, don't filter menu items. They receive clean, pre-filtered arrays and render them. That's it.
One service class. One source of truth. The menu, the permissions, and the routing all share the same data. Change a permission - the menu updates. Add a module - add an entry to the items array. No config files to sync. No database tables to maintain.
LaraFoundry is an open-source Laravel SaaS framework, being built in public and extracted from a production CRM/ERP.
- GitHub: github.com/dmitryisaenko/larafoundry
- Website: larafoundry.com
Top comments (0)