Tenant Resolution
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:
- Resolves the tenant from the request (checking multiple sources in priority order)
- Validates that the authenticated user has access to that tenant
- Sets three pieces of state if resolution succeeds:
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.
$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.
$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.
$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:
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:
$user = $request->user();
if ($user) {
return $user->tenants()->first();
}
return null;
Resolution Priority Summary
| Priority | Source | On Mismatch (Strict) | On Mismatch (Non-Strict) |
|---|---|---|---|
| 0 | Route parameter {tenantId} | 403 (always) | 403 (always) |
| 1 | X-Tenant-ID header | 403 | Falls through |
| 2 | Subdomain (slug lookup) | 403 | Falls through |
| 3 | Session current_tenant_id | Falls through | Falls through |
| 4 | User's first tenant | — | — |
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:
- Globally via configuration:
'strict_resolution' => env('TENANCY_STRICT_RESOLUTION', false),
- 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:
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.
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.
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:
| Check | Condition | HTTP Status | When It Happens |
|---|---|---|---|
| Tenant exists | current_tenant() is null | 400 | No tenant could be resolved at all |
| User authenticated | $request->user() is null | 401 | Token missing or invalid |
| User is member | belongsToTenant() returns false | 403 | User isn't in this tenant's pivot |
Onboarding Gate
The EnsureOnboardingComplete middleware prevents access to tenant features until the onboarding flow is finished:
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)
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)
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
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:
// 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
| Composable | Purpose |
|---|---|
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:
| Mode | How Tenant Is Sent | URL Example |
|---|---|---|
header | X-Tenant-ID request header | /api/v1/tenant |
path | Tenant ID in URL path | /api/v1/tenant/{tenantId}/invoices |
subdomain | Tenant slug as subdomain | acme.app.com/api/v1/invoices |
The useTenantRouting() composable provides a buildTenantUrl() function that generates the correct URL format based on the active mode:
// 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'
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
Multi-Tenancy Overview
How SaaS4Builders isolates tenant data with a single-database architecture: the Tenant model, user relationships, context helpers, and the middleware chain.
Data Scoping
How SaaS4Builders isolates tenant data at the query level: the BelongsToTenant trait, TenantScope global scope, auto-assignment, platform admin bypass, and how to make your own models tenant-aware.