DEV Community

Cover image for How I Built a Complete Multi-Tenancy System for My Laravel SaaS - Without Spatie
Dmitry Isaenko
Dmitry Isaenko

Posted on

How I Built a Complete Multi-Tenancy System for My Laravel SaaS - Without Spatie

Every SaaS application needs to answer one question on every single request: "who can do what in which company?"

Sounds simple. It's not.

I've been building Kohana.io - a SaaS CRM/ERP for small businesses. The multi-tenancy module took longer than any other module to get right. Not because the code is complex, but because the decisions are complex: single database or separate databases? Config-driven or database-driven permissions? How do you handle permission overrides? What happens when a user gets a 403?

Now I'm extracting this module into LaraFoundry - an open-source Laravel SaaS framework - so nobody has to make these decisions from scratch again.

Here's exactly how it works.


Architecture Overview

Component Purpose
'BelongsToCompany' trait Automatic query filtering by active company
'config/roles-and-permissions.php' All permissions defined in one place
Gate classes (8 files) Complex authorization logic per module
'HasRolesAndPermissions' trait 5-level permission hierarchy on User model
'SetActiveCompanyMiddleware' Auto-resolves tenant context
'CheckAccessMiddleware' Ban + payment status checks
'LayoutDataService' Permission-aware menu + FAR routing

Tech stack: Laravel 12, Inertia.js v2, Vue 3, Pest PHP


1. Data Isolation - The BelongsToCompany Trait

The scariest bug in multi-tenancy is a data leak. Showing Company A's orders to Company B.

It only takes one forgotten 'where('company_id', ...)' and you've got a GDPR nightmare.

LaraFoundry solves this at the Eloquent model level:

trait BelongsToCompany
{
    protected static function bootBelongsToCompany(): void
    {
        static::addGlobalScope('company', function (Builder $builder) {
            if (auth()->check()) {
                $companyId = auth()->user()->getCurrentCompanyId();
                if ($companyId) {
                    $builder->where(
                        $builder->getModel()->getTable() . '.company_id',
                        $companyId
                    );
                }
            }
        });
    }

    public function scopeForCompany(Builder $query, int $companyId): Builder
    {
        return $query->withoutGlobalScope('company')
            ->where($this->getTable() . '.company_id', $companyId);
    }

    public function scopeForAdmin(Builder $query): Builder
    {
        return $query->withoutGlobalScope('company');
    }
}
Enter fullscreen mode Exit fullscreen mode

Usage:

// Controller - no filtering needed
$orders = Order::query()->latest()->paginate(20);
// Automatically: SELECT * FROM orders WHERE company_id = 5

// Admin dashboard - see everything
$allOrders = Order::forAdmin()->latest()->paginate(50);

// Specific company query
$report = Order::forCompany(7)->where('status', 'completed')->get();
Enter fullscreen mode Exit fullscreen mode

The table prefix ('$builder->getModel()->getTable() . '.company_id'') prevents ambiguous column errors in joins. Small detail, saves hours of debugging.


2. Config-Driven Permission Registration

All permissions are defined in 'config/roles-and-permissions.php':

'permissions' => [
    'orders' => [
        'label' => 'Orders',
        'permissions' => [
            'orders.view' => 'View orders',
            'orders.create' => 'Create orders',
            'orders.update' => 'Update orders',
            'orders.delete' => 'Delete orders',
            'orders.approve' => 'Approve orders',
            'orders.status_change' => 'Change order status',
            'orders.export' => 'Export orders',
        ],
    ],
    'warehouse' => [
        'label' => 'Warehouse',
        'permissions' => [
            'warehouse.view' => 'View warehouse',
            'warehouse.create' => 'Add to warehouse',
            'warehouse.update' => 'Update warehouse',
            'warehouse.delete' => 'Delete from warehouse',
            'warehouse.inventory' => 'Inventory',
            'warehouse.transfer' => 'Transfer goods',
            'warehouse.reserve' => 'Reserve goods',
            'warehouse.assembly' => 'Order assembly',
        ],
    ],
    // 20+ modules, 100+ permissions total
],
Enter fullscreen mode Exit fullscreen mode

'AuthServiceProvider' reads this config and registers a Gate for each permission:

protected function registerPermissionGates(): void
{
    $permissions = $this->getAllPermissionsFromConfig();

    foreach ($permissions as $permissionSlug) {
        Gate::define($permissionSlug, function (User $user, ?Company $company = null) use ($permissionSlug) {
            $company = $company ?? $user->getActiveCompany();
            return $user->hasPermissionTo($permissionSlug, $company);
        });
    }
}
Enter fullscreen mode Exit fullscreen mode

One loop. All permissions registered. Add a new permission? Add one line to config, run 'php artisan permissions:sync'.


3. Dedicated Gate Classes for Complex Logic

Config handles 90% of permissions (simple CRUD). But some authorization logic has business rules. Like removing an employee:

// app/Gates/EmployeeGates.php
class EmployeeGates
{
    public static function register(): void
    {
        Gate::define('employees.remove', function (User $user, User $employee) {
            $company = $user->getActiveCompany();

            if ($user->id === $employee->id) return false;        // can't remove yourself
            if ($employee->isOwnerOf($company)) return false;      // can't remove the owner
            if (!$employee->companies->contains($company)) return false; // wrong company

            if ($user->isOwnerOf($company)) return true;           // owner can remove anyone

            return $user->hasPermissionTo('company.employees.remove', $company);
        });

        // employees.assignRole, employees.grantPermissions,
        // employees.manageRolesAndPermissions, employees.requestRemoval,
        // employees.confirmRemoval...
    }
}
Enter fullscreen mode Exit fullscreen mode

Each module gets its own Gate class. All registered from AuthServiceProvider:

protected function registerCustomGates(): void
{
    CompanyGates::register();
    EmployeeGates::register();
    RoleGates::register();
    ContragentGates::register();
    WarehouseGates::register();
    ProductionGates::register();
}
Enter fullscreen mode Exit fullscreen mode

Result: AuthServiceProvider stays under 60 lines. Each Gate class is independently testable.


4. The 5-Level Permission Hierarchy

This is the core of the system. Five levels, checked top to bottom:

Level 1: Super Admin  -> bypass everything
Level 2: Company Owner -> full access to their company
Level 3: Revoked       -> explicitly blocked (overrides roles!)
Level 4: Individual    -> explicitly granted (overrides roles)
Level 5: Role-based    -> permissions inherited from role
Enter fullscreen mode Exit fullscreen mode

The implementation:

public function hasPermissionTo(string $permissionSlug, Company|int|null $company = null): bool
{
    // Level 1: Super admin
    if ($this->isSuperAdmin()) return true;

    // Level 2: Company owner
    if ($company && $this->isOwnerOf($company)) return true;

    $companyId = $company instanceof Company ? $company->id : $company;

    // Level 3: Check if explicitly revoked
    $isRevoked = $this->permissions()
        ->where('permissions.slug', $permissionSlug)
        ->wherePivot('company_id', $companyId)
        ->wherePivot('is_revoked', true)
        ->exists();
    if ($isRevoked) return false;

    // Level 4: Check if explicitly granted
    $isGranted = $this->permissions()
        ->where('permissions.slug', $permissionSlug)
        ->wherePivot('company_id', $companyId)
        ->wherePivot('is_revoked', false)
        ->exists();
    if ($isGranted) return true;

    // Level 5: Check role permissions (company-scoped)
    return $this->roles()
        ->wherePivot('company_id', $companyId)
        ->whereHas('permissions', fn($q) => $q->where('slug', $permissionSlug))
        ->exists();
}
Enter fullscreen mode Exit fullscreen mode

Why this order matters:

Imagine a "Manager" role with 'orders.delete'. But one specific manager shouldn't delete orders. Instead of creating a new role, the company owner just revokes that one permission. Done.

The opposite works too - a "Worker" role doesn't have 'production.assign', but one senior worker needs it. Grant it individually.

The 'is_revoked' flag in the 'user_permissions' pivot table is what makes it all possible:

user_permissions: user_id, permission_id, company_id, is_revoked, granted_by_id
Enter fullscreen mode Exit fullscreen mode

One boolean. So much flexibility.


5. Role Templates & Custom Roles

When a new company is created, 5 role templates are automatically cloned:

'role_templates' => [
    'manager' => [
        'name' => 'Manager',
        'description' => 'Manage orders, contragents, view reports',
        'permissions' => [
            'orders.view', 'orders.create', 'orders.update', 'orders.delete',
            'contragents.viewAny', 'contragents.view', 'contragents.create',
            'production.view', 'production.create', 'dashboard.view',
            // ...20+ permissions
        ],
    ],
    'accountant' => [ /* ... */ ],
    'storekeeper' => [ /* ... */ ],
    'logistician' => [ /* ... */ ],
    'worker' => [ /* ... */ ],
],
Enter fullscreen mode Exit fullscreen mode

The company owner can:

  • Edit template roles (add/remove permissions)
  • Create completely custom roles
  • Assign multiple roles to one employee
  • Override any role permission per individual user
  • Delete custom roles (if no users are assigned)

All from the UI. No developer needed.


6. Middleware Stack - Tenant Context

Three middleware components handle the multi-tenancy lifecycle:

SetActiveCompanyMiddleware

Runs on every web request. Sets the active company context:

public function handle(Request $request, Closure $next): Response
{
    if (!auth()->check() || !auth()->user()->hasVerifiedEmail()) {
        return $next($request);
    }

    $user = auth()->user();

    // Already has active company? Verify ownership
    if (session()->has('active_company_id')) {
        $companyBelongsToUser = $user->companies()
            ->wherePivot('is_deleted', false)
            ->where('companies.id', session('active_company_id'))
            ->exists();

        if ($companyBelongsToUser) return $next($request);
        session()->forget('active_company_id');
    }

    // Priority: owned company > employee company
    $ownedCompany = $user->companies->first(
        fn($company) => $user->isOwnerOf($company)
    );
    $user->setActiveCompany($ownedCompany ?? $user->companies->first());

    return $next($request);
}
Enter fullscreen mode Exit fullscreen mode

If a user gets removed from a company, the middleware auto-switches on the next request. No stale state.

CheckAccessMiddleware

Checks ban status (user + owner) and payment status (trial + subscription). Even banned users can access support tickets.

CheckCompanyAccess

Owner-only routes: company settings, employee management, role configuration.

Middleware order in bootstrap/app.php:

1. HandleInertiaRequests
2. SetActiveCompanyMiddleware      <- tenant context
3. UpdateLastSessionActivity
4. SetLocale
5. EnsureEmailIsVerified
6. CheckPinLockMiddleware
7. CheckAccessMiddleware           <- ban + payment check
8. CheckSessionExists
Enter fullscreen mode Exit fullscreen mode

Order matters. You can't check company permissions before you know which company.


7. Permission-Aware Navigation

Every menu item has a 'policyName':

[
    'linkName' => __('Orders'),
    'policyName' => 'orders.view',
    'routeName' => 'orders',
],
[
    'linkName' => __('Warehouse'),
    'policyName' => 'warehouse.view',
    'routeName' => 'warehouse',
],
Enter fullscreen mode Exit fullscreen mode

Each item runs through 'checkUserAndCompanyPolicy()':

private function checkUserAndCompanyPolicy($policyName)
{
    if ($this->isAdmin()) return true;
    if (empty($policyName)) return false;
    if ($policyName === 'public') return auth()->check();

    $user = auth()->user();
    $company = $user->getActiveCompany();
    if (!$company) return false;

    if ($user->isOwnerOf($company)) return true;

    return $user->hasPermissionTo($policyName, $company);
}
Enter fullscreen mode Exit fullscreen mode

A "Storekeeper" sees: Warehouse. A "Manager" sees: Orders, Production, Contragents. An owner sees everything. If you can't access it - you don't see it.


8. The First Allowed Route (FAR) Pattern

The pattern that eliminated 403 pages from my SaaS entirely.

Instead of showing a 403 error page, redirect the user to the first page they CAN access:

// bootstrap/app.php - exception handler
$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

The 'getFirstAllowedRouteName()' method:

  1. Checks if user has a saved default page for this company
  2. If that page is still accessible - go there
  3. If not - clear it, show warning, fall back
  4. Walk through menu items, find the first accessible one
  5. Fallback to notifications
public function getFirstAllowedRouteName(): string
{
    if (self::isAdmin()) return 'admin.dashboard.index';

    if (!$this->authUserData->hasUserCompanyOrRole()) {
        return 'notifications.index';
    }

    // Check saved default route
    $saved = auth()->user()->getDefaultRouteForActiveCompany();
    if ($saved && $this->isBottomLevelRouteAccessible($saved)) {
        return $saved;
    }

    // Walk menu items
    foreach ($this->getNavUpLevelItemsArray() as $navItem) {
        if (!empty($navItem['unvisibly'])) continue;
        if ($this->checkUserAndCompanyPolicy($navItem['policyName'])) {
            return $this->getFirstAllowedBottomLevelRoute($navItem['routeName'])
                ?? $navItem['routeName'];
        }
    }

    return 'notifications.index';
}
Enter fullscreen mode Exit fullscreen mode

Used in 4 places: after login, after 403, after company switch, in sidebar "Home" link.


9. Testing - Proof It Works

Multi-tenancy bugs are silent data leaks. Automated tests are mandatory.

Permission hierarchy test:

it('respects priority: revoked > granted > role', function () {
    $employee->assignRole($managerRole, $company);

    // Manager has orders.view from role
    $employee->revokePermissionFrom($ordersView, $company);
    // Manager doesn't have warehouse.delete
    $employee->givePermissionTo($warehouseDelete, $company);

    expect($employee->hasPermissionTo('orders.view', $company))->toBeFalse()
        ->and($employee->hasPermissionTo('warehouse.delete', $company))->toBeTrue();
});
Enter fullscreen mode Exit fullscreen mode

Cross-company isolation:

it('denies updating role from different company', function () {
    $roleFromCompany2 = Role::create([
        'company_id' => $company2->id,
    ]);

    $this->actingAs($ownerOfCompany1);
    expect(Gate::denies('roles.update', $roleFromCompany2))->toBeTrue();
});
Enter fullscreen mode Exit fullscreen mode

Menu visibility:

it('employee with orders permission sees only orders', function () {
    $employee->givePermissionTo($ordersPermission, $company);

    $menu = $service->getNav('upLevel');
    $labels = collect($menu)->pluck('linkName')->toArray();

    expect($labels)
        ->toContain('Orders')
        ->not->toContain('Production')
        ->not->toContain('Warehouse')
        ->not->toContain('My company');
});
Enter fullscreen mode Exit fullscreen mode

Owner-only routes:

it('employee cannot access company settings page', function () {
    $response = $this->withCookie($cookieName, $sessionId)
        ->get(route('my_company.company_settings'));

    $response->assertRedirect();
    $response->assertSessionHas('message-disappear-error');
});
Enter fullscreen mode Exit fullscreen mode

Total test coverage: 19 test files covering permission hierarchy, cross-company isolation, Gate authorization, menu visibility, middleware chain, owner-only routes, role CRUD, employee management, and edge cases (no company, expired trial, banned owner, unverified email).


Database Schema

companies          id, name, created_by_id, trial_ends_at, subscription_ends_at
company_user       user_id, company_id, is_owner, is_deleted, default_route
roles              id, name, slug, company_id, is_global, is_template, is_custom
permissions        id, name, slug, module
role_permissions   role_id, permission_id
user_roles         user_id, role_id, company_id, assigned_by_id
user_permissions   user_id, permission_id, company_id, is_revoked, granted_by_id
Enter fullscreen mode Exit fullscreen mode

Why Not Spatie?

Spatie Permission is great for simpler setups. But it doesn't handle:

  • Company-scoped roles (same role slug, different permissions per company)
  • Permission overrides (revoke/grant individual permissions that override roles)
  • Role templates that clone on company creation
  • The 'is_revoked' pattern for granular control
  • Config-driven permission registration with artisan sync

LaraFoundry's system is purpose-built for multi-tenant SaaS where company owners manage their own teams.


What's Next

All of this is extracted from Kohana.io, where it runs in production handling real companies with real employees.

Star the repo or join the waitlist: larafoundry.com


This is part of a series where I'm building LaraFoundry in public. Previously: Registration Module, Authentication Module.

Top comments (0)