Skip to content
SaaS4Builders
Multi-Tenancy

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.

Once the tenant context is resolved, SaaS4Builders automatically filters all queries on tenant-scoped models so that each tenant only sees its own data. This happens at the Eloquent level through a global scope — no manual where('tenant_id', ...) clauses needed.


How Data Isolation Works

Data isolation in SaaS4Builders is application-level, not database-level. There are no PostgreSQL row-level security policies or separate schemas. Instead, every tenant-scoped model has a tenant_id column, and an Eloquent global scope adds a WHERE tenant_id = ? clause to every query automatically.

This approach has two components:

  1. BelongsToTenant trait — Applied to models that need tenant scoping. Registers the global scope and auto-assigns tenant_id on creation.
  2. TenantScope global scope — The Eloquent scope class that does the actual query filtering based on current_tenant().

The BelongsToTenant Trait

The BelongsToTenant trait is the primary mechanism for making a model tenant-aware. Adding it to a model does three things automatically:

backend/app/Models/Concerns/BelongsToTenant.php
trait BelongsToTenant
{
    public static function bootBelongsToTenant(): void
    {
        // 1. Register TenantScope as a global scope
        static::addGlobalScope(new TenantScope);

        // 2. Auto-set tenant_id on create if not already set
        static::creating(function ($model): void {
            if (! $model->tenant_id && $tenant = current_tenant()) {
                $model->tenant_id = $tenant->id;
            }
        });
    }

    public function initializeBelongsToTenant(): void
    {
        // 3. Ensure tenant_id is always fillable
        if (! in_array('tenant_id', $this->fillable)) {
            $this->fillable[] = 'tenant_id';
        }
    }

    public function tenant(): BelongsTo
    {
        return $this->belongsTo(Tenant::class);
    }

    public function scopeForTenant($query, Tenant $tenant)
    {
        return $query->where('tenant_id', $tenant->id);
    }
}

What Each Part Does

BehaviorMechanismWhen It Fires
Query filteringTenantScope global scope added via addGlobalScope()Every query (select, update, delete)
Auto-assignmentcreating event listener checks current_tenant()When a new record is created
FillableinitializeBelongsToTenant() adds tenant_id to $fillableOn model instantiation
Relationshiptenant() BelongsTo relationshipWhen you access $model->tenant
Explicit scopescopeForTenant() local scopeWhen you call Model::forTenant($tenant)
The trait automatically adds tenant_id to your model's $fillable array. You do not need to add it manually.

The TenantScope Global Scope

The TenantScope class is responsible for the actual query filtering:

backend/app/Models/Scopes/TenantScope.php
class TenantScope implements Scope
{
    public function apply(Builder $builder, Model $model): void
    {
        $tenant = current_tenant();

        // Skip if no tenant context
        if (! $tenant) {
            return;
        }

        // Platform admins can bypass tenant scope with special header
        $user = Auth::user();
        if ($user && method_exists($user, 'isPlatformAdmin') && $user->isPlatformAdmin()) {
            if (request()->header('X-Disable-Tenant-Scope')) {
                return;
            }
        }

        $builder->where($model->getTable().'.tenant_id', $tenant->id);
    }
}

Key behaviors:

  • No tenant context = no filtering. If current_tenant() returns null (e.g., in a console command or queue job without explicit context), the scope does nothing and the query returns all records across all tenants.
  • Platform admin bypass. If the authenticated user is a platform admin and sends the X-Disable-Tenant-Scope header, the scope is skipped. This enables cross-tenant admin dashboards.
  • Table-qualified column. The scope uses $model->getTable().'.tenant_id' to avoid ambiguity in join queries.
If no tenant context is set, TenantScope is silently skipped. This means unscoped queries can return data from all tenants. Always ensure tenant context is set in queue jobs and commands that handle tenant data — use set_current_tenant($tenant) before querying.

Making a Model Tenant-Aware

Adding tenant scoping to a new model takes two steps.

Step 1: Add tenant_id to the Migration

database/migrations/xxxx_create_project_notes_table.php
Schema::create('project_notes', function (Blueprint $table) {
    $table->uuid('id')->primary();
    $table->foreignUuid('tenant_id')->constrained()->cascadeOnDelete();
    $table->string('title');
    $table->text('body');
    $table->timestamps();

    $table->index('tenant_id');
});

The tenant_id column should be a UUID foreign key referencing the tenants table. Add cascadeOnDelete() if you want notes to be removed when a tenant is deleted.

Step 2: Use the Trait on the Model

app/Models/ProjectNote.php
use App\Models\Concerns\BelongsToTenant;

class ProjectNote extends Model
{
    use BelongsToTenant;
    use HasUuids;

    protected $fillable = [
        'title',
        'body',
        // tenant_id is added automatically by the trait
    ];
}

That's it. With these two steps:

  • Every query on ProjectNote automatically includes WHERE tenant_id = ?
  • New records automatically get tenant_id set from current_tenant()
  • You can access $note->tenant to get the owning tenant
  • You can use ProjectNote::forTenant($tenant) for explicit filtering

Tenant-Scoped vs. Global Models

The codebase contains two approaches to tenant scoping:

Models Using BelongsToTenant Trait

These models use the trait for automatic global scope filtering:

ModelLocationPurpose
Meterbackend/app/Models/Meter.phpUsage metering configuration
UsageEventbackend/app/Models/UsageEvent.phpIndividual usage tracking events

Models with tenant_id but No Trait

These models have a tenant_id column but are accessed through the Tenant model's relationships instead of the global scope:

ModelAccessed ViaWhy No Trait
Subscription$tenant->subscriptions()Always queried in tenant context through the relationship
InvoiceVia controller scoping with tenant_idSynced from Stripe, queried with explicit tenant filter
PaymentMethod$tenant->paymentMethods()Always tied to a specific tenant's Stripe customer
Invitation$tenant->invitations()Managed through team controllers that scope by tenant
TenantSettingVia settings repositoryAccessed through the settings resolution chain
ImpersonationLogVia admin controllersAudit records, queried in admin context
Models like Subscription and Invoice have a tenant_id column but don't use the BelongsToTenant trait. They are accessed through the Tenant model's relationships ($tenant->subscriptions()), which provides equivalent scoping through Eloquent's relationship queries. The trait is most useful for models that are queried independently, not always through a parent relationship.

Global Models (No Tenant Scoping)

These models represent the shared catalog and are visible to all tenants:

ModelPurpose
ProductProduct definitions (global catalog)
PlanPricing configurations
FeatureFunctional capabilities and entitlements
CurrencyCurrency reference data
UserUsers exist independently; linked to tenants via pivot

Platform Admin Bypass

Platform admins can bypass tenant scoping for cross-tenant operations by sending the X-Disable-Tenant-Scope header:

curl -H "Authorization: Bearer $TOKEN" \
     -H "X-Disable-Tenant-Scope: true" \
     https://api.example.com/api/v1/admin/meters

The bypass only works when both conditions are met:

  1. The authenticated user has is_platform_admin = true
  2. The request includes the X-Disable-Tenant-Scope header
The X-Disable-Tenant-Scope header only works for platform admins. Regular users cannot bypass tenant scoping, even if they send the header. The TenantScope class explicitly checks isPlatformAdmin() before honoring the header.

This is designed for admin dashboards that need to aggregate or display data across multiple tenants — for example, listing all meters or usage events across the platform.


Querying Without Tenant Context

Sometimes you need to query across tenants explicitly — in seeders, migrations, queue jobs, or admin features. There are two approaches:

Remove the Global Scope

Use Eloquent's withoutGlobalScope() to temporarily disable tenant filtering:

use App\Models\Scopes\TenantScope;

// Query all meters across all tenants
$allMeters = Meter::withoutGlobalScope(TenantScope::class)->get();

// Count usage events across all tenants
$totalEvents = UsageEvent::withoutGlobalScope(TenantScope::class)->count();

Use the Explicit Scope

The scopeForTenant() local scope lets you filter by a specific tenant without relying on the global context:

// Get meters for a specific tenant (regardless of current context)
$meters = Meter::withoutGlobalScope(TenantScope::class)
    ->forTenant($specificTenant)
    ->get();

Setting Context in Queue Jobs

If your queue job processes data for a specific tenant, set the context explicitly at the start:

class ProcessTenantReport implements ShouldQueue
{
    public function __construct(
        private string $tenantId
    ) {}

    public function handle(): void
    {
        $tenant = Tenant::find($this->tenantId);
        set_current_tenant($tenant);

        // All BelongsToTenant queries are now scoped to this tenant
        $meters = Meter::all(); // Only returns this tenant's meters
    }
}

Common Pitfalls

No Tenant Context in Queue Jobs

Queue jobs run outside of HTTP requests, so current_tenant() returns null by default. If your job queries BelongsToTenant models without setting context, it will return data from all tenants. Always call set_current_tenant() at the start of tenant-scoped jobs.

Creating Records in Seeders

Seeders also run without tenant context. If you create a BelongsToTenant model in a seeder without setting context or explicitly providing tenant_id, the column will be null and likely violate a foreign key constraint:

// Wrong — no tenant context in seeder
Meter::create(['name' => 'API Calls']); // tenant_id will be null

// Correct — set context first
set_current_tenant($tenant);
Meter::create(['name' => 'API Calls']); // tenant_id auto-set

// Also correct — provide tenant_id explicitly
Meter::create(['name' => 'API Calls', 'tenant_id' => $tenant->id]);

Forgetting to Reset Spatie Team Context

When switching between tenants in the same process (e.g., in a multi-tenant seeder or test), remember to update the Spatie Permission team context:

// Processing tenant A
set_current_tenant($tenantA);
setPermissionsTeamId($tenantA->id);
// ... work with tenant A ...

// Switching to tenant B
set_current_tenant($tenantB);
setPermissionsTeamId($tenantB->id); // Don't forget this!
// ... work with tenant B ...

If you forget to call setPermissionsTeamId(), Spatie role/permission checks will still be scoped to the previous tenant, leading to unexpected authorization failures.


What's Next

  • Testing Isolation — Test helpers and patterns for verifying that tenant data never leaks between organizations
  • Multi-Tenancy Overview — The Tenant model, user relationships, and the middleware chain