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');
}
}
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();
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
],
'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);
});
}
}
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...
}
}
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();
}
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
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();
}
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
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' => [ /* ... */ ],
],
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);
}
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
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',
],
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);
}
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'));
});
The 'getFirstAllowedRouteName()' method:
- Checks if user has a saved default page for this company
- If that page is still accessible - go there
- If not - clear it, show warning, fall back
- Walk through menu items, find the first accessible one
- 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';
}
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();
});
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();
});
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');
});
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');
});
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
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)