Skip to content
SaaS4Builders
Multi-Tenancy

Tenant Resolution

How SaaS4Builders determines the current tenant for each request: the 5-priority resolution chain, strict vs. non-strict mode, middleware enforcement, and the frontend tenant store.

Every API request that operates on tenant data must first determine which tenant the request targets. The ResolveTenant middleware handles this by checking multiple sources in priority order and setting the tenant context for the rest of the request lifecycle.


How the Current Tenant Is Determined

When a request arrives, the ResolveTenant middleware:

  1. Resolves the tenant from the request (checking multiple sources in priority order)
  2. Validates that the authenticated user has access to that tenant
  3. Sets three pieces of state if resolution succeeds:
backend/app/Http/Middleware/ResolveTenant.php
if ($tenant) {
    $this->tenantContext->set($tenant);       // TenantContext singleton
    set_current_tenant($tenant);              // Global helper
    setPermissionsTeamId($tenant->id);        // Spatie Permission team scope
}

After this middleware runs, current_tenant() returns the resolved tenant, all BelongsToTenant models are automatically filtered, and Spatie Permission role checks are scoped to the correct tenant.


The Resolution Priority Chain

The middleware checks five sources in order. The first source that yields a valid, accessible tenant wins.

Priority 0: Route Parameter {tenantId}

When the tenant ID is explicitly in the URL (e.g., /api/v1/tenant/{tenantId}/invoices), this takes absolute priority. There is no fallback — if the user cannot access this tenant, the request returns 403.

backend/app/Http/Middleware/ResolveTenant.php
$routeTenantId = $request->route('tenantId');
if ($routeTenantId !== null) {
    return $this->resolveFromRouteParameter($routeTenantId);
}

Priority 1: X-Tenant-ID Header

The client can specify the target tenant via an X-Tenant-ID request header. The middleware looks up the tenant and checks that the user is either a member or a platform admin.

backend/app/Http/Middleware/ResolveTenant.php
$tenantId = $request->header('X-Tenant-ID');
if ($tenantId) {
    $tenant = $this->findTenantForUser($tenantId);
    if ($tenant) {
        return $tenant;
    }

    // In strict mode, return 403 if tenant was requested but not accessible
    if ($strictMode) {
        return $this->tenantAccessDeniedResponse($tenantId);
    }
    // In non-strict mode, fall through to other priorities
}

Priority 2: Subdomain

The middleware extracts the subdomain from the request host (e.g., acme from acme.app.com) and looks up a tenant by its slug field. Reserved subdomains are skipped.

backend/app/Http/Middleware/ResolveTenant.php
$host = $request->getHost();
$parts = explode('.', $host);
$subdomain = $parts[0];

$reservedSubdomains = config('tenancy.reserved_subdomains', ['www', 'api', 'localhost']);

if ($subdomain && ! in_array($subdomain, $reservedSubdomains)) {
    $tenant = Tenant::where('slug', $subdomain)->first();
    if ($tenant) {
        $validatedTenant = $this->validateTenantAccess($tenant);
        if ($validatedTenant) {
            return $validatedTenant;
        }
    }
}

Priority 3: Session

For SPA mode with sessions, the middleware checks for a current_tenant_id stored in the session:

backend/app/Http/Middleware/ResolveTenant.php
if ($request->hasSession()) {
    $sessionTenantId = $request->session()->get('current_tenant_id');
    if ($sessionTenantId) {
        $tenant = $this->findTenantForUser($sessionTenantId);
        if ($tenant) {
            return $tenant;
        }
    }
}

Priority 4: User's First Tenant (Fallback)

If no other source yielded a tenant, the middleware falls back to the authenticated user's first tenant:

backend/app/Http/Middleware/ResolveTenant.php
$user = $request->user();
if ($user) {
    return $user->tenants()->first();
}

return null;

Resolution Priority Summary

PrioritySourceOn Mismatch (Strict)On Mismatch (Non-Strict)
0Route parameter {tenantId}403 (always)403 (always)
1X-Tenant-ID header403Falls through
2Subdomain (slug lookup)403Falls through
3Session current_tenant_idFalls throughFalls through
4User's first tenant
Route parameter resolution (priority 0) never falls back to other methods. If the user lacks access to the tenant in the URL, the request returns 403 even if X-Tenant-ID points to a valid tenant. This prevents URL manipulation attacks.

Strict vs. Non-Strict Mode

The resolution behavior when a user requests an inaccessible tenant depends on the strict mode setting.

Non-strict mode (default): The middleware silently falls through to the next priority source. The user gets their own tenant context — they never see an error, but they also never see another tenant's data.

Strict mode: The middleware immediately returns a 403 response with a TENANT_ACCESS_DENIED error code.

Strict mode can be enabled in two ways:

  1. Globally via configuration:
backend/config/tenancy.php
'strict_resolution' => env('TENANCY_STRICT_RESOLUTION', false),
  1. Per-route via middleware parameter:
Route::middleware('tenant.resolve:strict')->group(function () {
    // These routes will always return 403 for inaccessible tenants
});

The per-route parameter overrides the global setting.

Error Response

When strict mode blocks a request, the response format is:

{
    "message": "Access denied to this tenant",
    "code": "TENANT_ACCESS_DENIED",
    "tenantId": "550e8400-e29b-41d4-a716-446655440000"
}

Platform Admin Access

Platform admins (users with is_platform_admin = true) receive special treatment during resolution:

backend/app/Http/Middleware/ResolveTenant.php
private function findTenantForUser(?string $tenantId): ?Tenant
{
    if (! $tenantId || ! Str::isUuid($tenantId)) {
        return null;
    }

    $user = request()->user();
    if (! $user) {
        return null;
    }

    // Platform admins can access any tenant
    if ($user->isPlatformAdmin()) {
        return Tenant::find($tenantId);
    }

    return $user->tenants()->where('tenant_id', $tenantId)->first();
}

The ResolveTenant middleware will resolve any tenant for a platform admin, regardless of membership. However, this does not bypass the EnsureTenantMember middleware — platform admins who are not members of a tenant are still blocked on tenant-scoped routes.

Platform admin status lets you resolve any tenant, but you are still blocked by tenant.member on tenant-scoped routes unless you are an actual member. Use admin routes (/api/v1/admin/tenants/{tenant}) for cross-tenant management operations.

Membership Enforcement

After the tenant is resolved, the EnsureTenantMember middleware enforces that the user actually belongs to that tenant.

backend/app/Http/Middleware/EnsureTenantMember.php
class EnsureTenantMember
{
    public function handle(Request $request, Closure $next): Response
    {
        $tenant = current_tenant();
        $user = $request->user();

        if (! $tenant) {
            abort(400, 'No tenant context found.');
        }

        if (! $user) {
            abort(401);
        }

        if (! $user->belongsToTenant($tenant)) {
            abort(403, 'You are not a member of this tenant.');
        }

        return $next($request);
    }
}

This middleware runs three checks in order:

CheckConditionHTTP StatusWhen It Happens
Tenant existscurrent_tenant() is null400No tenant could be resolved at all
User authenticated$request->user() is null401Token missing or invalid
User is memberbelongsToTenant() returns false403User isn't in this tenant's pivot

Onboarding Gate

The EnsureOnboardingComplete middleware prevents access to tenant features until the onboarding flow is finished:

backend/app/Http/Middleware/EnsureOnboardingComplete.php
final class EnsureOnboardingComplete
{
    public function handle(Request $request, Closure $next): Response
    {
        $tenant = current_tenant();

        if (! $tenant) {
            return $next($request);
        }

        if ($tenant->isOnboardingComplete()) {
            return $next($request);
        }

        return response()->json([
            'message' => __('onboarding.onboarding_incomplete'),
            'code' => 'ONBOARDING_INCOMPLETE',
        ], 403);
    }
}

This middleware is a soft gate — if no tenant context exists (e.g., on auth-only routes), it passes through. It only blocks when a tenant exists but hasn't marked onboarding as complete.


Route Structure

The API routes are organized into three groups with different middleware stacks:

Protected Routes (Auth + Tenant Resolution)

backend/routes/api.php
Route::middleware(['auth:sanctum', 'tenant.resolve'])->group(function () {
    // Auth routes (logout, /me) — no tenant membership required
    Route::prefix('auth')->group(function () {
        Route::post('/logout', [AuthController::class, 'logout']);
        Route::get('/me', [AuthController::class, 'me']);
    });

    // Tenant routes — require membership + completed onboarding
    Route::middleware(['tenant.member', 'onboarding.complete'])->group(function () {
        Route::get('/tenant', [TenantController::class, 'show']);
        Route::patch('/tenant', [TenantController::class, 'update']);
    });
});

Tenant-Scoped Routes (Explicit Tenant ID in URL)

backend/routes/api.php
Route::prefix('tenant/{tenantId}')
    ->middleware(['tenant.member', 'onboarding.complete'])
    ->group(function () {
        Route::get('invoices', [InvoiceController::class, 'index']);
        Route::get('subscription', [SubscriptionController::class, 'show']);
        Route::get('team/members', [TeamMemberController::class, 'index']);
        // ...
    });

When {tenantId} is in the URL, the ResolveTenant middleware uses priority 0 — the route parameter — and enforces access strictly (403 on mismatch, no fallback).

Platform Admin Routes

backend/routes/api.php
Route::middleware(['auth:sanctum', 'impersonation.prevent', 'platform.admin'])
    ->prefix('admin')
    ->group(function () {
        Route::get('tenants', [AdminTenantController::class, 'index']);
        Route::get('tenants/{tenant}', [AdminTenantController::class, 'show']);
        // ...
    });

Admin routes do not use tenant.resolve or tenant.member. They use the platform.admin middleware which checks is_platform_admin on the user and sets the Spatie permission scope to a global context.


Frontend Tenant Management

The frontend manages tenant context through a set of composables built on a Pinia store.

The Tenant Store

The useTenantStore() Pinia store is the single source of truth for tenant state on the frontend:

frontend/features/foundation/tenancy/stores/useTenantStore.ts
// Key state
currentTenant: Tenant | null       // The active tenant
userTenants: UserTenant[]          // All tenants the user belongs to

// Key computed
tenantId: string | undefined       // Current tenant ID
hasTenant: boolean                 // Whether a tenant is set
isMultiTenant: boolean             // Whether the user has multiple tenants
currentTenantRole: string | null   // User's role in the current tenant
currentTenantPermissions: string[] // User's permissions in the current tenant

The store exposes a switchTenant() action that calls POST /api/v1/tenant/{tenantId}/switch to switch the active tenant. The backend validates membership before allowing the switch.

Composables

ComposablePurpose
useCurrentTenant()Readonly facade over the store. Exposes tenant, tenantId, hasTenant, currentTenantRole, and permission-checking helpers
useTenantSwitch()Handles tenant switching with loading state, validation, and error handling
useTenantRouting()Builds tenant-aware URLs and routes based on the configured tenancy mode (header, path, or subdomain)
useOrganizationDetails()Fetches and updates the tenant's organization profile (name, billing info)

Tenancy Modes

The frontend supports three modes for communicating the current tenant to the backend:

ModeHow Tenant Is SentURL Example
headerX-Tenant-ID request header/api/v1/tenant
pathTenant ID in URL path/api/v1/tenant/{tenantId}/invoices
subdomainTenant slug as subdomainacme.app.com/api/v1/invoices

The useTenantRouting() composable provides a buildTenantUrl() function that generates the correct URL format based on the active mode:

frontend/features/foundation/tenancy/composables/useTenantRouting.ts
// Header mode: path is unchanged
buildTenantUrl('/team/members')
// → '/team/members' (X-Tenant-ID header added by API client)

// Path mode: tenant ID prepended
buildTenantUrl('/team/members')
// → '/{tenantId}/team/members'

// Subdomain mode: full URL with tenant subdomain
buildTenantUrl('/team/members')
// → 'https://acme.app.com/team/members'
The frontend API client only sends the X-Tenant-ID header when the tenancy mode is set to header. In path mode, the tenant is already in the URL. In subdomain mode, it's in the hostname.

Frontend Resolution

The useCurrentTenant() composable can resolve the tenant identifier from the current URL, depending on the mode:

  • Path mode: Extracts from route.params (e.g., /tenant/:tenantId/...)
  • Subdomain mode: Extracts from window.location.host (e.g., acme.app.com)
  • Header mode: No URL resolution — uses the value stored in the Pinia store

The composable also provides an isTenantContextValid computed that checks whether the tenant in the URL matches the tenant in the store, helping detect stale navigation state.


What's Next

  • Data Scoping — How the resolved tenant context automatically filters all database queries on tenant-scoped models
  • Testing Isolation — How to write tests that verify tenant resolution and data isolation