Skip to content
SaaS4Builders
Multi-Tenancy

Testing Multi-Tenant Isolation

How to write tests that verify tenant data isolation: the WithTenantContext trait, test helpers, cross-tenant access patterns, strict mode testing, and common pitfalls.

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.

backend/tests/Traits/WithTenantContext.php
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

HelperReturnsWhat 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()UserNew user attached to existing tenant with specified role
createPlatformAdmin()UserUser 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

backend/tests/Feature/Tenancy/TenantIsolationTest.php
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

backend/tests/Feature/Tenancy/TenantIsolationTest.php
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

backend/tests/Feature/Tenancy/TenantIsolationTest.php
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:

backend/tests/Feature/Tenancy/TenantResolutionTest.php
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:

backend/tests/Feature/Tenancy/TenantResolutionTest.php
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:

backend/tests/Feature/Tenancy/TenantResolutionTest.php
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:

backend/tests/Feature/Tenancy/StrictTenantResolutionTest.php
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:

backend/tests/Feature/Tenancy/StrictTenantResolutionTest.php
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

backend/tests/Feature/Authorization/PlatformAdminBoundaryTest.php
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

backend/tests/Feature/Authorization/PlatformAdminBoundaryTest.php
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

backend/tests/Feature/Tenancy/TenantMembershipTest.php
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:

backend/tests/TestCase.php
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();
The 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'));
When testing multi-tenant scenarios, always call 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:

  1. Enable strict mode: Config::set('tenancy.strict_resolution', true)
  2. 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 FileWhat It Tests
TenantIsolationTestCross-tenant data access, membership checks, multi-tenant users
TenantResolutionTestHeader resolution, fallback, invalid UUIDs, tenant switching
StrictTenantResolutionTestStrict mode 403s, non-strict fallback, edge cases
TenantMembershipTestTenant 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:

  1. Create two users with separate tenants using createUserWithTenant()
  2. Create tenant-scoped data for both tenants
  3. Authenticate as user A and verify they can only see tenant A's data
  4. 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.