DEV Community

Cover image for Building a Dynamic, Permission-Aware Navigation System for Multi-Tenant Laravel SaaS
Dmitry Isaenko
Dmitry Isaenko

Posted on

Building a Dynamic, Permission-Aware Navigation System for Multi-Tenant Laravel SaaS

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
Enter fullscreen mode Exit fullscreen mode

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
]
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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',
Enter fullscreen mode Exit fullscreen mode

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'],
]
Enter fullscreen mode Exit fullscreen mode

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';
}
Enter fullscreen mode Exit fullscreen mode

This handles four critical scenarios:

  1. After login - where should the user land?
  2. After 403 - redirect instead of showing error page
  3. After company switch - permissions may differ
  4. 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'));
});
Enter fullscreen mode Exit fullscreen mode

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
    ]
]
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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 },
        ]
    }
]
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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'));
});
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (0)