Testing Multi-Tenant Isolation
Tenant data isolation is one of the most critical invariants in a multi-tenant application. SaaS4Builders provides a dedicated test trait with helper methods that make it straightforward to verify that tenant A can never see tenant B's data.
The WithTenantContext Trait
Every test that involves tenant context uses the WithTenantContext trait. It provides four helper methods that handle the boilerplate of creating users with tenants, setting up authentication, and configuring the Spatie Permission team context.
trait WithTenantContext
{
protected function ensureCurrenciesExist(): void
{
$currencies = [
['code' => 'EUR', 'name' => 'Euro', 'symbol' => '€', 'minor_units' => 2, 'is_active' => true],
['code' => 'USD', 'name' => 'US Dollar', 'symbol' => '$', 'minor_units' => 2, 'is_active' => true],
['code' => 'GBP', 'name' => 'British Pound', 'symbol' => '£', 'minor_units' => 2, 'is_active' => true],
];
foreach ($currencies as $currency) {
Currency::firstOrCreate(
['code' => $currency['code']],
$currency
);
}
}
protected function createUserWithTenant(
array $userAttributes = [],
array $tenantAttributes = []
): array {
$this->ensureCurrenciesExist();
$user = User::factory()->create($userAttributes);
$tenant = Tenant::create(array_merge([
'name' => $user->name."'s Organization",
'slug' => Str::slug($user->name.'-'.Str::random(4)),
'owner_id' => $user->id,
'onboarding_completed_at' => now(),
], $tenantAttributes));
$user->tenants()->attach($tenant->id, [
'joined_at' => now(),
]);
// Assign admin role via Spatie
setPermissionsTeamId($tenant->id);
$user->assignRole('admin');
return [
'user' => $user->fresh(),
'tenant' => $tenant->fresh(),
];
}
protected function actingAsWithTenant(User $user, ?Tenant $tenant = null): static
{
if ($tenant === null) {
$tenant = $user->tenants()->first();
}
if ($tenant) {
set_current_tenant($tenant);
setPermissionsTeamId($tenant->id);
}
return $this->actingAs($user, 'sanctum');
}
protected function createTenantMember(
Tenant $tenant,
string $role = 'member',
array $userAttributes = []
): User {
$user = User::factory()->create($userAttributes);
$user->tenants()->attach($tenant->id, [
'joined_at' => now(),
]);
setPermissionsTeamId($tenant->id);
$user->assignRole($role);
return $user->fresh();
}
protected function createPlatformAdmin(array $attributes = []): User
{
$user = User::factory()->create(array_merge([
'is_platform_admin' => true,
], $attributes));
// Direct DB insert because Spatie's assignRole() checks guard
// against the User model's default guard (web), causing GuardDoesNotMatch
$role = Role::where('name', 'platform-admin')
->where('guard_name', 'sanctum')
->firstOrFail();
DB::table('model_has_roles')->insert([
'role_id' => $role->id,
'model_type' => get_class($user),
'model_id' => $user->id,
'tenant_id' => '00000000-0000-0000-0000-000000000000',
]);
app(PermissionRegistrar::class)->forgetCachedPermissions();
return $user->fresh();
}
}
Test Helpers Reference
| Helper | Returns | What It Sets Up |
|---|---|---|
createUserWithTenant() | ['user' => User, 'tenant' => Tenant] | User + tenant + pivot attachment + Spatie admin role + currencies |
actingAsWithTenant() | $this (chainable) | set_current_tenant() + setPermissionsTeamId() + actingAs() via Sanctum |
createTenantMember() | User | New user attached to existing tenant with specified role |
createPlatformAdmin() | User | User with is_platform_admin = true + platform-admin Spatie role |
createUserWithTenant()
Creates a complete user-with-tenant setup in one call. The tenant is created with onboarding marked as complete, and the user is assigned the admin role via Spatie Permission (scoped to the tenant).
$data = $this->createUserWithTenant();
$user = $data['user']; // User with admin role
$tenant = $data['tenant']; // Tenant owned by this user
// With custom attributes
$data = $this->createUserWithTenant(
userAttributes: ['name' => 'Jane Doe'],
tenantAttributes: ['name' => 'Acme Corp', 'slug' => 'acme']
);
actingAsWithTenant()
Authenticates as a user and sets the tenant context in a single call. This is critical — using actingAs() alone does not set the tenant context, which means current_tenant() returns null and BelongsToTenant scopes won't filter correctly.
// Authenticate with user's first tenant
$this->actingAsWithTenant($user);
// Authenticate with a specific tenant
$this->actingAsWithTenant($user, $specificTenant);
createTenantMember()
Adds a new user to an existing tenant with a specified role. Useful for testing permission boundaries within a tenant.
$member = $this->createTenantMember($tenant); // Default: 'member' role
$admin = $this->createTenantMember($tenant, 'admin'); // Admin role
$viewer = $this->createTenantMember($tenant, 'member', [ // With custom attributes
'name' => 'Read-Only User',
]);
createPlatformAdmin()
Creates a platform admin user. Uses a direct database insert for the Spatie role assignment because Spatie's assignRole() method checks the guard against the model's default guard (web), which causes a GuardDoesNotMatch exception since the platform-admin role uses the sanctum guard.
$admin = $this->createPlatformAdmin();
$admin->isPlatformAdmin(); // true
Testing Tenant Isolation
The core isolation pattern creates two separate tenants and verifies that one user cannot see the other tenant's data.
User Cannot Access Another Tenant
public function test_user_cannot_access_other_tenant(): void
{
$data1 = $this->createUserWithTenant();
$data2 = $this->createUserWithTenant();
$token = $data1['user']->createToken('test')->plainTextToken;
// Try to access other tenant via header
$response = $this->withHeaders([
'Authorization' => "Bearer $token",
'X-Tenant-ID' => $data2['tenant']->id,
])->getJson('/api/v1/tenant');
// In non-strict mode: user gets their own tenant (fallback behavior)
$response->assertStatus(200);
$this->assertEquals($data1['tenant']->id, $response->json('tenant.id'));
}
In non-strict mode, requesting an inaccessible tenant doesn't return an error — it silently falls back to the user's own tenant. The key assertion is that the response data belongs to the requesting user's tenant, not the targeted tenant.
Membership Check
public function test_user_belongs_to_tenant_check(): void
{
$data1 = $this->createUserWithTenant();
$data2 = $this->createUserWithTenant();
$this->assertTrue($data1['user']->belongsToTenant($data1['tenant']));
$this->assertFalse($data1['user']->belongsToTenant($data2['tenant']));
}
Multiple Tenant Membership
public function test_user_can_belong_to_multiple_tenants(): void
{
$data = $this->createUserWithTenant();
// Create second tenant and add user
$tenant2 = Tenant::create([
'name' => 'Second Org',
'slug' => 'second-org',
'owner_id' => $data['user']->id,
]);
$data['user']->tenants()->attach($tenant2->id, [
'joined_at' => now(),
]);
// Assign member role via Spatie
setPermissionsTeamId($tenant2->id);
$data['user']->assignRole('member');
$this->assertCount(2, $data['user']->fresh()->tenants);
$this->assertTrue($data['user']->belongsToTenant($data['tenant']));
$this->assertTrue($data['user']->belongsToTenant($tenant2));
}
Testing Tenant Resolution
Header-Based Resolution
The standard pattern for authenticated, tenant-scoped requests:
public function test_tenant_resolved_from_header(): void
{
$data = $this->createUserWithTenant();
$token = $data['user']->createToken('test')->plainTextToken;
$response = $this->withHeaders([
'Authorization' => "Bearer $token",
'X-Tenant-ID' => $data['tenant']->id,
])->getJson('/api/v1/tenant');
$response->assertStatus(200);
$this->assertEquals($data['tenant']->id, $response->json('tenant.id'));
}
Fallback to First Tenant
When no tenant identifier is provided, the middleware falls back to the user's first tenant:
public function test_tenant_falls_back_to_first_tenant_when_no_header(): void
{
$data = $this->createUserWithTenant();
$token = $data['user']->createToken('test')->plainTextToken;
$response = $this->withHeader('Authorization', "Bearer $token")
->getJson('/api/v1/tenant');
$response->assertStatus(200);
$this->assertEquals($data['tenant']->id, $response->json('tenant.id'));
}
Invalid UUID Handling
Invalid UUIDs in the X-Tenant-ID header are gracefully ignored:
public function test_tenant_header_with_invalid_uuid_falls_back_to_user_tenant(): void
{
$data = $this->createUserWithTenant();
$token = $data['user']->createToken('test')->plainTextToken;
$response = $this->withHeaders([
'Authorization' => "Bearer $token",
'X-Tenant-ID' => 'invalid-uuid',
])->getJson('/api/v1/tenant');
$response->assertStatus(200);
$this->assertEquals($data['tenant']->id, $response->json('tenant.id'));
}
Testing Strict Mode
Enable strict mode with Config::set() to test 403 responses for inaccessible tenants:
public function test_strict_mode_returns_403_for_inaccessible_tenant(): void
{
Config::set('tenancy.strict_resolution', true);
$data1 = $this->createUserWithTenant();
$data2 = $this->createUserWithTenant();
$token = $data1['user']->createToken('test')->plainTextToken;
$response = $this->withHeaders([
'Authorization' => "Bearer $token",
'X-Tenant-ID' => $data2['tenant']->id,
])->getJson('/api/v1/tenant');
$response->assertStatus(403)
->assertJson([
'code' => 'TENANT_ACCESS_DENIED',
'tenantId' => $data2['tenant']->id,
]);
}
Compare with non-strict mode in the same test file:
public function test_non_strict_mode_falls_back_to_user_tenant(): void
{
Config::set('tenancy.strict_resolution', false);
$data1 = $this->createUserWithTenant();
$data2 = $this->createUserWithTenant();
$token = $data1['user']->createToken('test')->plainTextToken;
$response = $this->withHeaders([
'Authorization' => "Bearer $token",
'X-Tenant-ID' => $data2['tenant']->id,
])->getJson('/api/v1/tenant');
// Falls back to user's own tenant instead of returning 403
$response->assertStatus(200);
$this->assertEquals($data1['tenant']->id, $response->json('tenant.id'));
}
Testing Platform Admin Boundaries
Platform admin status lets you resolve any tenant, but EnsureTenantMember still blocks access to tenant-scoped routes unless the admin is an actual member.
Admin Non-Member Blocked on Tenant Routes
public function test_platform_admin_non_member_blocked_on_tenant_routes(): void
{
$admin = $this->createPlatformAdmin();
$data = $this->createUserWithTenant();
$response = $this->actingAs($admin, 'sanctum')
->withHeader('X-Tenant-ID', $data['tenant']->id)
->getJson("/api/v1/tenant/{$data['tenant']->id}/entitlements");
$response->assertForbidden();
}
Admin Who Is Member Can Access
public function test_platform_admin_who_is_member_can_use_tenant_routes(): void
{
$admin = $this->createPlatformAdmin();
$data = $this->createUserWithTenant();
// Explicitly add the admin as a member
$admin->tenants()->attach($data['tenant']->id, ['joined_at' => now()]);
setPermissionsTeamId($data['tenant']->id);
$admin->assignRole('admin');
$response = $this->actingAs($admin, 'sanctum')
->withHeader('X-Tenant-ID', $data['tenant']->id)
->getJson("/api/v1/tenant/{$data['tenant']->id}/entitlements");
$response->assertOk();
}
This distinction is intentional. Platform admin routes (/api/v1/admin/tenants/{tenant}) exist for cross-tenant management. Tenant-scoped routes require membership to prevent accidental data exposure through admin accounts.
Testing Membership and Permissions
Role-Based Access Control
public function test_admin_can_update_tenant(): void
{
$data = $this->createUserWithTenant();
$this->actingAsWithTenant($data['user'], $data['tenant'])
->patchJson('/api/v1/tenant', ['name' => 'Updated Name'])
->assertOk();
}
public function test_member_cannot_update_tenant(): void
{
$data = $this->createUserWithTenant();
$member = $this->createTenantMember($data['tenant']);
$this->actingAsWithTenant($member, $data['tenant'])
->patchJson('/api/v1/tenant', ['name' => 'Hacked Name'])
->assertForbidden();
}
Test Infrastructure
Base TestCase Setup
The base TestCase class handles critical cleanup that prevents test pollution:
protected function setUp(): void
{
parent::setUp();
$this->ensureCurrenciesExist();
}
protected function tearDown(): void
{
setPermissionsTeamId(null); // Reset Spatie team context
app(PermissionRegistrar::class)->forgetCachedPermissions();
Mockery::close();
parent::tearDown();
}
The tearDown() method resets Spatie's team context and clears the permission cache. Without this, role/permission state from one test can leak into the next test, causing intermittent failures.
Common Testing Pitfalls
Foreign Key on preferred_currency
The tenants table has a foreign key constraint on preferred_currency referencing the currencies table. If you create tenants without ensuring currencies exist first, you'll get a constraint violation:
// This will fail with FK constraint error
Tenant::create(['name' => 'Test', 'slug' => 'test', 'owner_id' => $user->id]);
// Use createUserWithTenant() — it calls ensureCurrenciesExist() automatically
$data = $this->createUserWithTenant();
createUserWithTenant() helper calls ensureCurrenciesExist() automatically. If you create tenants manually in tests, call $this->ensureCurrenciesExist() first.Forgetting to Set Spatie Team Context
When checking roles or permissions in tests, you must set the Spatie team context to the correct tenant. Without this, hasRole() checks are scoped to the wrong (or no) tenant:
// Wrong — no team context set
$this->assertTrue($user->hasRole('admin')); // May fail unexpectedly
// Correct — set team context first
setPermissionsTeamId($tenant->id);
$this->assertTrue($user->hasRole('admin'));
setPermissionsTeamId() before checking or assigning Spatie roles. Spatie Permission uses team context to scope roles, and forgetting to set it is the most common cause of role-related test failures.Non-Strict Mode Fallback Surprises
In non-strict mode (the default), requesting an inaccessible tenant does not return an error. The middleware silently falls back to the user's own tenant. If your test expects a 403, you need to either:
- Enable strict mode:
Config::set('tenancy.strict_resolution', true) - Use route-parameter-based endpoints where
{tenantId}in the URL always enforces access (no fallback)
// This does NOT return 403 in non-strict mode
$this->withHeader('X-Tenant-ID', $otherTenant->id)
->getJson('/api/v1/tenant')
->assertStatus(200); // Returns user's own tenant, not 403
// This DOES return 403 — route parameter has no fallback
$this->getJson("/api/v1/tenant/{$otherTenant->id}/team/members")
->assertForbidden();
Using actingAs() Without Tenant Context
Laravel's built-in actingAs() only sets the authenticated user — it does not set the tenant context. Use actingAsWithTenant() from the trait instead:
// Wrong — no tenant context set
$this->actingAs($user, 'sanctum')
->getJson('/api/v1/tenant');
// current_tenant() returns null, BelongsToTenant queries return everything
// Correct — tenant context, Spatie team, and auth all set
$this->actingAsWithTenant($user, $tenant)
->getJson('/api/v1/tenant');
The createPlatformAdmin Guard Workaround
The createPlatformAdmin() helper uses a direct database insert instead of Spatie's assignRole() method. This is because the platform-admin role is registered on the sanctum guard, while the User model's default guard is web. Spatie's assignRole() throws a GuardDoesNotMatch exception when these don't align.
If you need to assign additional roles to a platform admin within a tenant context, use the standard Spatie methods after setting the team context:
$admin = $this->createPlatformAdmin();
// Add admin to a tenant with a role
$admin->tenants()->attach($tenant->id, ['joined_at' => now()]);
setPermissionsTeamId($tenant->id);
$admin->assignRole('admin'); // This works because 'admin' uses the default guard
Test File Organization
All tenancy-related tests are organized under backend/tests/Feature/Tenancy/:
| Test File | What It Tests |
|---|---|
TenantIsolationTest | Cross-tenant data access, membership checks, multi-tenant users |
TenantResolutionTest | Header resolution, fallback, invalid UUIDs, tenant switching |
StrictTenantResolutionTest | Strict mode 403s, non-strict fallback, edge cases |
TenantMembershipTest | Tenant CRUD permissions, role-based access, slug uniqueness |
Platform admin boundary tests are in backend/tests/Feature/Authorization/PlatformAdminBoundaryTest.php.
Writing Your Own Isolation Tests
When you add a new tenant-scoped feature, follow this pattern:
- Create two users with separate tenants using
createUserWithTenant() - Create tenant-scoped data for both tenants
- Authenticate as user A and verify they can only see tenant A's data
- Verify user A cannot access tenant B's data (either 403 or fallback depending on endpoint)
public function test_tenant_a_cannot_see_tenant_b_project_notes(): void
{
$dataA = $this->createUserWithTenant();
$dataB = $this->createUserWithTenant();
// Create data in each tenant's context
set_current_tenant($dataA['tenant']);
ProjectNote::create(['title' => 'Note A', 'body' => 'Tenant A data']);
set_current_tenant($dataB['tenant']);
ProjectNote::create(['title' => 'Note B', 'body' => 'Tenant B data']);
// User A should only see their own note
$this->actingAsWithTenant($dataA['user'], $dataA['tenant'])
->getJson('/api/v1/tenant/'.$dataA['tenant']->id.'/project-notes')
->assertOk()
->assertJsonCount(1, 'data')
->assertJsonPath('data.0.title', 'Note A');
}
This pattern is the foundation of every tenant isolation test in the codebase. Adapt it for your specific models and endpoints.
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.
Teams Overview
Team architecture in SaaS4Builders: tenant membership, built-in roles, Spatie Permissions integration, TeamContext scoping, and domain structure.