Data Scoping
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:
BelongsToTenanttrait — Applied to models that need tenant scoping. Registers the global scope and auto-assignstenant_idon creation.TenantScopeglobal scope — The Eloquent scope class that does the actual query filtering based oncurrent_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:
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
| Behavior | Mechanism | When It Fires |
|---|---|---|
| Query filtering | TenantScope global scope added via addGlobalScope() | Every query (select, update, delete) |
| Auto-assignment | creating event listener checks current_tenant() | When a new record is created |
| Fillable | initializeBelongsToTenant() adds tenant_id to $fillable | On model instantiation |
| Relationship | tenant() BelongsTo relationship | When you access $model->tenant |
| Explicit scope | scopeForTenant() local scope | When you call Model::forTenant($tenant) |
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:
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()returnsnull(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-Scopeheader, 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.
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
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
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
ProjectNoteautomatically includesWHERE tenant_id = ? - New records automatically get
tenant_idset fromcurrent_tenant() - You can access
$note->tenantto 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:
| Model | Location | Purpose |
|---|---|---|
Meter | backend/app/Models/Meter.php | Usage metering configuration |
UsageEvent | backend/app/Models/UsageEvent.php | Individual 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:
| Model | Accessed Via | Why No Trait |
|---|---|---|
Subscription | $tenant->subscriptions() | Always queried in tenant context through the relationship |
Invoice | Via controller scoping with tenant_id | Synced 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 |
TenantSetting | Via settings repository | Accessed through the settings resolution chain |
ImpersonationLog | Via admin controllers | Audit records, queried in admin context |
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:
| Model | Purpose |
|---|---|
Product | Product definitions (global catalog) |
Plan | Pricing configurations |
Feature | Functional capabilities and entitlements |
Currency | Currency reference data |
User | Users 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:
- The authenticated user has
is_platform_admin = true - The request includes the
X-Disable-Tenant-Scopeheader
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
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.
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.