Skip to content
SaaS4Builders
Multi-Tenancy

Multi-Tenancy Overview

How SaaS4Builders isolates tenant data with a single-database architecture: the Tenant model, user relationships, context helpers, and the middleware chain.

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:

StrategyHow It WorksTrade-off
Multi-databaseEach tenant gets its own databaseStrong isolation, but complex provisioning, migrations, and connection management
Single-databaseAll tenants share one database, scoped by tenant_idSimpler 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
If your business requires database-level isolation (e.g., for regulatory compliance in healthcare or finance), you can extend this architecture by adding database-per-tenant provisioning. The 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.

backend/app/Models/Tenant.php
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

GroupFieldsPurpose
Identityname, slugDisplay name and unique URL-safe identifier
Ownershipowner_idFK to the user who created the tenant
Billinglegal_name, address, city, postal_code, country, vat_number, billing_email, preferred_currency, stripe_customer_idInvoicing and Stripe integration
Statesettings (JSON), onboarding_completed_atConfiguration and lifecycle tracking

Helper Methods

The Tenant model includes several convenience methods for common checks:

backend/app/Models/Tenant.php
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()]);
}
The Tenant model uses 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:

ColumnTypeDescription
tenant_idUUID (FK)References tenants.id
user_idInteger (FK)References users.id (cascade delete)
joined_atTimestampWhen 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:

backend/app/Models/User.php
// 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:

backend/app/Models/Tenant.php
// 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:

backend/app/Support/Services/TenantContext.php
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:

backend/app/Support/Helpers/tenant.php
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.

The 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.

ModelTenant-ScopedScoping MethodNotes
TenantIs the tenant itselfRoot entity
SubscriptionYesTenant::subscriptions() relationshipTenant ↔ Plan binding
InvoiceYesVia tenant_id (controller scoping)Synced from Stripe
PaymentMethodYesTenant::paymentMethods() relationshipStored payment methods
InvitationYesTenant::invitations() relationshipTeam invitations
MeterYesBelongsToTenant trait + global scopeUsage tracking config
UsageEventYesBelongsToTenant trait + global scopeIndividual usage events
TenantSettingYesVia tenant_idPer-tenant configuration
ImpersonationLogYesVia tenant_idAdmin audit trail
ProductNoGlobal catalogShared across all tenants
PlanNoGlobal catalogPricing configurations
FeatureNoGlobal catalogFunctional capabilities
CurrencyNoGlobal referenceCurrency definitions
UserNoLinked via pivotUsers 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:

backend/bootstrap/app.php
$middleware->alias([
    'tenant.resolve'     => ResolveTenant::class,
    'tenant.member'      => EnsureTenantMember::class,
    'onboarding.complete' => EnsureOnboardingComplete::class,
    // ...
]);
MiddlewareAliasWhat It DoesFails With
ResolveTenanttenant.resolveDetermines the current tenant from the request (header, subdomain, route, session, or fallback) and sets the tenant context403 (strict mode only)
EnsureTenantMembertenant.memberVerifies the authenticated user is a member of the resolved tenant400, 401, or 403
EnsureOnboardingCompleteonboarding.completeBlocks requests if the tenant hasn't completed onboarding403

These middleware are layered in the route definitions:

backend/routes/api.php
// 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 BelongsToTenant trait and TenantScope automatically 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