Multi-Tenancy Overview
SaaS4Builders uses a single-database, multi-tenant architecture where every tenant's data lives in the same database, isolated by a tenant_id column. This approach provides clean data separation without the operational complexity of managing multiple databases.
Why Single-Database Tenancy
Multi-tenant SaaS applications typically choose between two isolation strategies:
| Strategy | How It Works | Trade-off |
|---|---|---|
| Multi-database | Each tenant gets its own database | Strong isolation, but complex provisioning, migrations, and connection management |
| Single-database | All tenants share one database, scoped by tenant_id | Simpler operations, easier migrations, sufficient for 90% of SaaS products |
SaaS4Builders uses the single-database approach. Every tenant-scoped table includes a tenant_id column, and the application automatically filters queries to the current tenant using Eloquent global scopes. This gives you:
- One migration path — schema changes apply to all tenants at once
- Simple backups — one database to back up and restore
- No connection pooling complexity — a single database connection handles everything
- Straightforward querying — cross-tenant analytics and admin features work with standard queries
BelongsToTenant trait and middleware chain remain useful even in that scenario.The Tenant Model
The Tenant model represents a customer organization. It uses ordered UUIDs as primary keys for globally unique, index-friendly identifiers.
class Tenant extends Model
{
use HasFactory;
use HasUuids;
use SoftDeletes;
protected $keyType = 'string';
public $incrementing = false;
protected $fillable = [
'name',
'slug',
'owner_id',
'settings',
'legal_name',
'address',
'city',
'postal_code',
'country',
'vat_number',
'billing_email',
'preferred_currency',
'stripe_customer_id',
'onboarding_completed_at',
];
protected function casts(): array
{
return [
'settings' => 'array',
'onboarding_completed_at' => 'datetime',
];
}
public function newUniqueId(): string
{
return (string) Str::orderedUuid();
}
}
Field Groups
| Group | Fields | Purpose |
|---|---|---|
| Identity | name, slug | Display name and unique URL-safe identifier |
| Ownership | owner_id | FK to the user who created the tenant |
| Billing | legal_name, address, city, postal_code, country, vat_number, billing_email, preferred_currency, stripe_customer_id | Invoicing and Stripe integration |
| State | settings (JSON), onboarding_completed_at | Configuration and lifecycle tracking |
Helper Methods
The Tenant model includes several convenience methods for common checks:
public function isOnboardingComplete(): bool
{
return $this->onboarding_completed_at !== null;
}
public function hasActiveSubscription(): bool
{
return $this->subscriptions()
->whereIn('status', [SubscriptionStatus::Active, SubscriptionStatus::Trialing])
->exists();
}
public function hasBillingDetails(): bool
{
return $this->legal_name !== null
&& $this->address !== null
&& $this->city !== null
&& $this->postal_code !== null
&& $this->country !== null
&& $this->billing_email !== null;
}
public function billingContactEmail(): ?string
{
return $this->billing_email ?? $this->owner?->email;
}
public function markOnboardingComplete(): void
{
$this->update(['onboarding_completed_at' => now()]);
}
SoftDeletes. Deleting a tenant preserves its data for audit purposes — all tenant-scoped data remains intact but inaccessible through normal queries.User-Tenant Relationship
Users and tenants are connected through a many-to-many relationship via the tenant_user pivot table. Each tenant has one owner (the user who created it) and can have many members.
Pivot Table
The tenant_user table tracks membership with a joined_at timestamp:
| Column | Type | Description |
|---|---|---|
tenant_id | UUID (FK) | References tenants.id |
user_id | Integer (FK) | References users.id (cascade delete) |
joined_at | Timestamp | When the user joined the tenant |
A unique constraint on (tenant_id, user_id) prevents duplicate memberships.
Relationship Methods
The User model provides three methods for tenant access:
// All tenants this user belongs to
public function tenants(): BelongsToMany
{
return $this->belongsToMany(Tenant::class)
->withPivot('joined_at')
->withTimestamps();
}
// Tenants this user owns (created)
public function ownedTenants(): HasMany
{
return $this->hasMany(Tenant::class, 'owner_id');
}
// Check membership in a specific tenant
public function belongsToTenant(Tenant $tenant): bool
{
return $this->tenants()->where('tenant_id', $tenant->id)->exists();
}
The Tenant model provides the inverse:
// The user who created this tenant
public function owner(): BelongsTo
{
return $this->belongsTo(User::class, 'owner_id');
}
// All users who belong to this tenant
public function users(): BelongsToMany
{
return $this->belongsToMany(User::class)
->withPivot('joined_at')
->withTimestamps();
}
Tenant Context
Every request that operates on tenant data needs to know which tenant is active. SaaS4Builders manages this through a request-scoped TenantContext service and a set of global helper functions.
The TenantContext Service
TenantContext is a singleton that holds the current tenant for the duration of a request:
final class TenantContext
{
private ?Tenant $tenant = null;
public function set(?Tenant $tenant): void
{
$this->tenant = $tenant;
}
public function get(): ?Tenant
{
return $this->tenant;
}
public function id(): ?string
{
return $this->tenant?->id;
}
public function has(): bool
{
return $this->tenant !== null;
}
public function clear(): void
{
$this->tenant = null;
}
}
It is registered as a singleton in the service container, so every class that injects or resolves it within the same request gets the same instance.
Global Helper Functions
Four helper functions provide convenient access to the tenant context from anywhere in the application:
function current_tenant(): ?Tenant
{
return app(TenantContext::class)->get();
}
function current_tenant_id(): ?string
{
return app(TenantContext::class)->id();
}
function set_current_tenant(?Tenant $tenant): void
{
app(TenantContext::class)->set($tenant);
}
function has_tenant(): bool
{
return app(TenantContext::class)->has();
}
These helpers are autoloaded via composer.json and available throughout the application — in controllers, actions, queries, middleware, and even global scopes.
current_tenant() helper returns null outside of a request with tenant context (e.g., in queue jobs or Artisan commands). Always null-check before accessing tenant properties in these contexts, or set the tenant explicitly with set_current_tenant().Scoped vs. Global Resources
Not everything in the application is tenant-scoped. The catalog (products, plans, features, currencies) is global — all tenants see the same pricing and feature definitions.
| Model | Tenant-Scoped | Scoping Method | Notes |
|---|---|---|---|
| Tenant | — | Is the tenant itself | Root entity |
| Subscription | Yes | Tenant::subscriptions() relationship | Tenant ↔ Plan binding |
| Invoice | Yes | Via tenant_id (controller scoping) | Synced from Stripe |
| PaymentMethod | Yes | Tenant::paymentMethods() relationship | Stored payment methods |
| Invitation | Yes | Tenant::invitations() relationship | Team invitations |
| Meter | Yes | BelongsToTenant trait + global scope | Usage tracking config |
| UsageEvent | Yes | BelongsToTenant trait + global scope | Individual usage events |
| TenantSetting | Yes | Via tenant_id | Per-tenant configuration |
| ImpersonationLog | Yes | Via tenant_id | Admin audit trail |
| Product | No | Global catalog | Shared across all tenants |
| Plan | No | Global catalog | Pricing configurations |
| Feature | No | Global catalog | Functional capabilities |
| Currency | No | Global reference | Currency definitions |
| User | No | Linked via pivot | Users exist independently |
See Data Scoping for a detailed explanation of how automatic query filtering works.
The Middleware Chain
Tenant isolation is enforced through a chain of three middleware, registered as aliases in bootstrap/app.php:
$middleware->alias([
'tenant.resolve' => ResolveTenant::class,
'tenant.member' => EnsureTenantMember::class,
'onboarding.complete' => EnsureOnboardingComplete::class,
// ...
]);
| Middleware | Alias | What It Does | Fails With |
|---|---|---|---|
ResolveTenant | tenant.resolve | Determines the current tenant from the request (header, subdomain, route, session, or fallback) and sets the tenant context | 403 (strict mode only) |
EnsureTenantMember | tenant.member | Verifies the authenticated user is a member of the resolved tenant | 400, 401, or 403 |
EnsureOnboardingComplete | onboarding.complete | Blocks requests if the tenant hasn't completed onboarding | 403 |
These middleware are layered in the route definitions:
// Layer 1: Auth + tenant resolution
Route::middleware(['auth:sanctum', 'tenant.resolve'])->group(function () {
// Layer 2: Membership + onboarding enforcement
Route::middleware(['tenant.member', 'onboarding.complete'])->group(function () {
Route::get('/tenant', [TenantController::class, 'show']);
Route::patch('/tenant', [TenantController::class, 'update']);
});
// Tenant-scoped routes with 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']);
// ...
});
});
The first layer resolves which tenant the request targets. The second layer enforces that the user actually belongs to that tenant and that the tenant has completed its setup. This separation allows some routes (like logout or /auth/me) to work without requiring full tenant membership.
What's Next
- Tenant Resolution — How the middleware determines which tenant a request targets, including the 5-priority resolution chain and strict mode
- Data Scoping — How the
BelongsToTenanttrait andTenantScopeautomatically filter queries, and how to make your own models tenant-aware - Testing Isolation — Test helpers and patterns for verifying that tenant data never leaks between organizations
Currency Rules
Multi-currency architecture in SaaS4Builders: the Money value object, currency invariants, zero-decimal handling, resolution chain, and domain enforcement.
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.