Multi-Tenancy

Lightpack provides built-in support for row-level multi-tenancy through the TenantModel class—making it simple to build SaaS applications and multi-tenant systems.


Understanding Multi-Tenancy

What is a Tenant?

A tenant is an isolated group of users who share common access to your application with specific privileges. In practical terms:

Key Principle: Each tenant's data must be completely isolated from other tenants—users in Tenant A should never see or access data belonging to Tenant B.

Real-World Example

Imagine you're building a project management SaaS application:

Tenants in your application:
-------------------------------------------------
Tenant ID | Organization Name    | Users
-------------------------------------------------
1         | Acme Corporation     | john@acme.com, jane@acme.com
2         | Beta Industries      | bob@beta.com, alice@beta.com
3         | Gamma Solutions      | charlie@gamma.com
-------------------------------------------------

When john@acme.com logs in:

This isolation is what multi-tenancy provides.


Multi-Tenancy Architectural Approaches

There are 3 main architectural approaches to multi-tenancy:

Approach 1: Database-per-Tenant (Separate Database)

Tenant 1 → database_tenant_1
Tenant 2 → database_tenant_2
Tenant 3 → database_tenant_3

Pros: Maximum isolation, easy backup per tenant
Cons: Resource intensive, complex connection management, scaling issues


Approach 2: Schema-per-Tenant (Separate Schema)

Same Database:
  ├─ schema_tenant_1 (tables: posts, users)
  ├─ schema_tenant_2 (tables: posts, users)
  └─ schema_tenant_3 (tables: posts, users)

Pros: Good isolation, easier than separate databases
Cons: Schema switching overhead, complex migrations


Approach 3: Row-Level Tenancy (Shared Tables with Discriminator Column) ⭐

posts table:
-------------------------------------------------
id | tenant_id | title
-------------------------------------------------
1  | 1         | Post A
2  | 1         | Post B
3  | 2         | Post C  ← Different tenant

Pros: Simple, scalable, database-agnostic, efficient
Cons: Requires careful query filtering, less isolation

Lightpack chose Approach 3 (Row-Level Tenancy) because it provides the best balance for most applications:


Quick Start

Step 1: Create Tenant-Aware Schema

Add a tenant identifier column tenant_id to your tables:

CREATE TABLE posts (
    id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
    tenant_id INT NOT NULL,
    title VARCHAR(255) NOT NULL,
    content TEXT,
    created_at TIMESTAMP NULL,
    updated_at TIMESTAMP NULL,

    INDEX idx_tenant_id (tenant_id)
);

Important: Always index your tenant column for optimal performance.

Step 2: Create Tenant Models

Use the console command to generate a tenant model:

php console create:model Post --tenant

This creates a model that extends TenantModel:

<?php

namespace App\Models;

use Lightpack\Database\Lucid\TenantModel;

class Post extends TenantModel
{
    protected $table = 'posts';
}

Step 3: Set Tenant Context

Set the tenant context using TenantContext:

use Lightpack\Database\Lucid\TenantContext;

// After authenticating user
TenantContext::set($user->tenant_id);

Step 4: Use Models Normally

Once tenant context is set, all queries are automatically scoped:

// Only returns posts for current tenant
$posts = Post::query()->all();

// Auto-assigns tenant_id on create
$post = new Post();
$post->title = 'My Post';
$post->save(); // tenant_id automatically set

// Only updates current tenant's posts
$post->title = 'Updated Title';
$post->save();

// Only deletes current tenant's posts
$post->delete();

How It Works

Automatic Query Filtering

The TenantModel uses a globalScope() to automatically add a WHERE tenant_id = ? clause to all queries:

// Your code
Post::query()->where('status', 'published')->all();

// Actual SQL executed
SELECT * FROM posts WHERE status = 'published' AND tenant_id = 1

This applies to all operations:

Automatic Tenant Assignment

When creating new records, TenantModel automatically sets the tenant column:

$post = new Post();
$post->title = 'New Post';
$post->save();
// tenant_id is automatically set to current tenant

This works for both save() and insert() methods, ensuring you never accidentally create records without a tenant.


Customizing Tenant Column

By default, TenantModel uses tenant_id as the tenant column. You can customize this per model:

class Article extends TenantModel
{
    protected $table = 'articles';
    protected $tenantColumn = 'site_id';  // Custom column name
}

Tenant Resolution Strategies

You set the tenant context using TenantContext. Here are common patterns for different application types:

Strategy 1: Session-Based

Best for traditional web applications with cookie-based authentication.

// In your route filter
use Lightpack\Database\Lucid\TenantContext;

$user = auth()->user();
TenantContext::set(session()->get('tenant.id'));

// Or get from authenticated user
TenantContext::set($user->tenant_id);

When to use:

Strategy 2: JWT/Token-Based (API)

Best for RESTful APIs, mobile apps, and SPAs.

// In your API authentication middleware
use Lightpack\Database\Lucid\TenantContext;

$user = auth()->user(); // From JWT token
TenantContext::set($user->tenant_id);

When to use:

Strategy 3: Domain/Subdomain-Based

Best for SaaS applications where each tenant has their own domain or subdomain (e.g., acme.yourapp.com).

// In your route filter or middleware
use Lightpack\Database\Lucid\TenantContext;

$domain = request()->host();
$tenant = Tenant::query()->where('domain', $domain)->one();

if ($tenant) {
    TenantContext::set($tenant->id);
}

When to use:

Performance Tip: Cache domain-to-tenant lookups to avoid database queries on every request.

Bypassing Tenant Isolation

Sometimes you need to access data across all tenants—for example, in admin dashboards or analytics. Use queryWithoutScopes() to bypass tenant filtering:

// Access all posts from all tenants
$allPosts = Post::queryWithoutScopes()->all();

// Count posts across all tenants
$totalPosts = Post::queryWithoutScopes()->count();

// Get posts from specific tenant
$tenant2Posts = Post::queryWithoutScopes()
    ->where('tenant_id', 2)
    ->all();

Security Warning: Only use queryWithoutScopes() in admin areas with proper authorization checks.


Summary

Lightpack's multi-tenancy system provides:

Built-in multi-tenancy - No packages needed
Automatic isolation - Queries filtered automatically via TenantModel
Flexible tenant context - Set via TenantContext from any source
Simple API - TenantContext::set(), get(), clear(), has()
Clean separation - Context management separate from model logic
Production-ready - Comprehensive test coverage

This makes it ideal for building SaaS applications and multi-tenant systems with minimal complexity and maximum flexibility.