ForgeMultiTenant

Multi-tenancy support for Forge Kernel. Provides multiple database strategies, domain-based tenant identification, automatic tenant scoping, and tenant-specific migrations and seeders.

Overview

ForgeMultiTenant provides comprehensive multi-tenancy support for Forge Kernel applications. It enables a single application instance to serve multiple tenants with complete data isolation, automatic tenant resolution, and flexible database strategies.

Key Features

Multiple database strategies (COLUMN, VIEW, DB)
Domain and subdomain-based tenant identification
Central domain support for admin functionality
Automatic tenant resolution from Host header
Tenant-scoped models with automatic query filtering
Route scoping (central vs tenant)
Tenant-specific migrations and seeders
Automatic connection management

What ForgeMultiTenant Provides

  • Multiple Database Strategies: COLUMN (shared database), VIEW (database views), and DB (separate databases)
  • Domain-Based Identification: Automatic tenant resolution from domain or subdomain
  • Central Domain: Special handling for admin/central functionality separate from tenants
  • Automatic Tenant Scoping: Models and queries automatically filtered to current tenant
  • Route Scoping: Controllers can be scoped to central or tenant routes
  • Connection Management: Automatic database connection setup based on tenant strategy
  • Query Rewriting: Automatic WHERE tenant_id injection for COLUMN strategy
  • CLI Tools: Tenant-specific migrations and seeders with preview support

Generic Module: ForgeMultiTenant is a generic module (type: 'generic', order: 2), providing multi-tenancy functionality that can be installed as needed. It depends on ForgeDatabaseSql for database operations.

Architecture & Design Philosophy

ForgeMultiTenant is built with flexibility, performance, and security in mind.

Multi-Tenancy Patterns

ForgeMultiTenant supports three common multi-tenancy patterns:

  • Shared Database, Shared Schema (COLUMN): All tenants share the same database and schema, isolated by tenant_id column
  • Shared Database, Separate Views (VIEW): All tenants share the same database, but use database views for isolation
  • Separate Databases (DB): Each tenant has its own database with complete isolation

Strategy-Based Database Isolation

Each tenant can use a different strategy:

  • Strategy is configured per tenant in the tenants table
  • Connection factory automatically uses the correct strategy
  • Query rewriting only active for COLUMN strategy
  • Different strategies can coexist in the same application

Domain-Based Tenant Identification

Tenants are identified automatically from the request:

  • TenantMiddleware reads Host header
  • TenantManager resolves tenant from domain/subdomain
  • Central domain is excluded from tenant resolution
  • Local development (localhost) is handled gracefully

Automatic Connection Management

Database connections are managed automatically:

  • TenantConnectionFactory creates connections based on strategy
  • Connections are cached per tenant
  • Container is updated with tenant-specific connection
  • QueryBuilder is recreated with tenant connection

Installation

ForgeMultiTenant can be installed via ForgePackageManager.

Using ForgePackageManager

# Install with wizard (interactive)
php forge.php package:install-module

# Install directly (skip wizard)
php forge.php package:install-module --module=ForgeMultiTenant

# Install specific version
php forge.php package:install-module --module=ForgeMultiTenant@0.1.8

Dependencies

ForgeMultiTenant requires:

  • ForgeDatabaseSql: Required for database operations and migrations

Post-Install Setup

The module automatically runs migrations and seeders on install:

# Automatically runs:
# 1. Module migrations (creates tenants table)
# 2. Module seeders (creates default tenant)
# 3. Tenant migrations (runs app/Database/Migrations/Tenants)
# 4. Tenant seeders (runs app/Database/Seeders/Tenants)

Configuration

Set the central domain in your environment:

CENTRAL_DOMAIN=forge-v3.test

Multi-Tenancy Strategies

ForgeMultiTenant supports three database strategies, each with different isolation levels and use cases.

COLUMN Strategy

Same database, tenant_id column for isolation:

  • All tenants share the same database and schema
  • Data isolation via tenant_id column
  • Queries automatically filtered by tenant_id
  • Query rewriting via TenantQueryRewriter
  • Most cost-effective for many tenants
  • Best for: SaaS applications with many small tenants
// Tenant configuration
[
    'id' => 'tenant-1',
    'domain' => 'example.com',
    'subdomain' => 'tenant1',
    'strategy' => 'column',
    'db_name' => null,  // Uses default database
    'connection' => null
]

VIEW Strategy

Same database, database views for isolation:

  • All tenants share the same database
  • Uses PostgreSQL/SQLite database views
  • SET app.tenant variable for view filtering
  • Automatic view-based filtering
  • Better isolation than COLUMN strategy
  • Best for: Applications needing better data isolation without separate databases
// Tenant configuration
[
    'id' => 'tenant-2',
    'domain' => 'example.com',
    'subdomain' => 'tenant2',
    'strategy' => 'view',
    'db_name' => null,
    'connection' => null
]

// Connection automatically sets:
// SET app.tenant = 'tenant-2'

DB Strategy

Separate database per tenant:

  • Each tenant has its own database
  • Complete data isolation
  • Separate database connections
  • Connections cached per tenant
  • Best for: High-security requirements, large tenants, compliance needs
// Tenant configuration
[
    'id' => 'tenant-3',
    'domain' => 'tenant3.com',
    'subdomain' => null,
    'strategy' => 'database',
    'db_name' => 'tenant3_db',  // Separate database name
    'connection' => null
]

// SQLite: Creates storage/Database/tenant3_db.sqlite
// MySQL/PostgreSQL: Uses tenant3_db database

Strategy Comparison

Strategy Isolation Cost Use Case
COLUMN Low Lowest Many small tenants
VIEW Medium Low Better isolation needed
DB High Higher High security, compliance

Tenant Identification

Tenants are automatically identified from the request's Host header.

Domain-Based Identification

Tenants are resolved by matching the request domain:

  • TenantMiddleware reads Host header from request
  • TenantManager resolves tenant from domain/subdomain
  • Matches against tenants table
  • Sets tenant in request attributes

Subdomain Support

Tenants can be identified by subdomain:

// Tenant configuration
[
    'id' => 'my-tenant',
    'domain' => 'forge-v3.test',
    'subdomain' => 'my-tenant',  // Subdomain
    'strategy' => 'column'
]

// Resolves from: my-tenant.forge-v3.test

Full Domain Support

Tenants can also use full domains:

// Tenant configuration
[
    'id' => 'enterprise-tenant',
    'domain' => 'enterprise.com',
    'subdomain' => null,  // No subdomain
    'strategy' => 'database'
]

// Resolves from: enterprise.com

Central Domain

The central domain is excluded from tenant resolution:

# Environment configuration
CENTRAL_DOMAIN=forge-v3.test

# Requests to forge-v3.test are NOT resolved as tenants
# Used for admin/central functionality

Local Development

Local development hosts are handled gracefully:

  • localhost, 127.0.0.1, [::1], ::1 are treated as central domain
  • No tenant resolution for local development
  • Allows development without domain configuration

Tenant Model & DTO

Tenants are represented by the Tenant DTO and managed by TenantManager.

Tenant DTO Structure

final readonly class Tenant
{
    public function __construct(
        public string $id,           // Unique tenant identifier
        public string $domain,        // Base domain
        public ?string $subdomain,    // Optional subdomain
        public Strategy $strategy,    // COLUMN, VIEW, or DB
        public ?string $dbName = null, // Database name (for DB strategy)
        public ?string $connection = null
    ) {}
}

TenantManager Service

TenantManager provides tenant resolution and access:

use App\Modules\ForgeMultiTenant\Services\TenantManager;

// Resolve tenant from domain
$tenant = $tenantManager->resolveByDomain('tenant1.example.com');

// Get current tenant
$current = $tenantManager->current();

// Get current tenant ID
$tenantId = $tenantManager->tenantId();

// Get all tenants
$allTenants = $tenantManager->all();

// Find specific tenant
$tenant = $tenantManager->find('tenant-id');

tenant() Helper Function

Use the helper function to get the current tenant:

use function App\Modules\ForgeMultiTenant\helpers\tenant;

// Get current tenant
$tenant = tenant();

if ($tenant) {
    echo "Current tenant: {$tenant->id}";
    echo "Strategy: {$tenant->strategy->value}";
}

Tenant Scoping (Models)

Models can be automatically scoped to the current tenant using the #[TenantScoped] attribute and TenantScopedTrait.

Tenant-Scoped Model

use App\Modules\ForgeMultiTenant\Attributes\TenantScoped;
use App\Modules\ForgeMultiTenant\Traits\TenantScopedTrait;
use App\Modules\ForgeSqlOrm\ORM\Model;

#[TenantScoped]
#[Table("posts")]
class Post extends Model
{
    use TenantScopedTrait;

    #[Column(cast: Cast::STRING)]
    public string $tenant_id;  // Required for COLUMN strategy

    // ... other columns
}

How It Works

  • #[TenantScoped] attribute marks the model as tenant-scoped
  • TenantScopedTrait overrides newQuery() method
  • TenantQueryRewriter automatically adds WHERE tenant_id = ?
  • Only active for COLUMN strategy
  • Works with ModelQuery and QueryBuilder

Automatic Query Filtering

// This query:
$posts = Post::query()->where('status', '=', 'active')->get();

// Automatically becomes:
// SELECT * FROM posts WHERE tenant_id = 'current-tenant-id' AND status = 'active'

// All queries are automatically scoped to current tenant

Requirements

  • Model must have tenant_id column (for COLUMN strategy)
  • Use #[TenantScoped] attribute on model class
  • Use TenantScopedTrait in model
  • Query rewriting only works with COLUMN strategy

Route Scoping (Controllers)

Controllers and routes can be scoped to central or tenant contexts using the #[TenantScope] attribute.

Controller-Level Scoping

use App\Modules\ForgeMultiTenant\Attributes\TenantScope;

#[TenantScope("central")]
final class HomeController
{
    // All routes in this controller are central-only
    // Not accessible from tenant domains
}

Method-Level Scoping

class MixedController
{
    #[TenantScope("central")]
    public function adminDashboard(): Response
    {
        // Only accessible from central domain
    }

    #[TenantScope("tenant")]
    public function tenantDashboard(): Response
    {
        // Only accessible from tenant domains
    }

    public function publicPage(): Response
    {
        // Accessible from both central and tenant domains
    }
}

Scope Values

  • "central": Route only accessible from central domain
  • "tenant": Route only accessible from tenant domains
  • No attribute: Route accessible from both

ScopeMiddleware Enforcement

ScopeMiddleware automatically enforces route scoping:

  • Checks #[TenantScope] attribute on controller or method
  • Validates current request context (central vs tenant)
  • Returns 403 error if scope mismatch
  • Runs after TenantMiddleware (order: 2)

Middleware

ForgeMultiTenant provides two middleware for tenant resolution and route scoping.

TenantMiddleware

Resolves tenant from domain and sets up tenant-specific connection:

  • Runs first in middleware stack (order: 1)
  • Reads Host header from request
  • Resolves tenant via TenantManager
  • Sets tenant in request attributes
  • Creates tenant-specific database connection
  • Updates container with tenant connection
  • Recreates QueryBuilder with tenant connection
// TenantMiddleware flow:
1. Extract Host header
2. Resolve tenant from domain
3. If tenant found:
   - Set tenant in request attributes
   - Create tenant connection via TenantConnectionFactory
   - Update DatabaseConnectionInterface in container
   - Update PDO in container
   - Recreate QueryBuilderInterface with tenant connection
4. Continue to next middleware

ScopeMiddleware

Enforces route scoping based on #[TenantScope] attributes:

  • Runs after TenantMiddleware (order: 2)
  • Extracts #[TenantScope] from controller or method
  • Checks current request context (central vs tenant)
  • Returns 403 error if scope mismatch
  • Allows request to continue if scope matches
// ScopeMiddleware validation:
if (scope === 'central' && tenant !== null) {
    return 403; // Tenant trying to access central route
}

if (scope === 'tenant' && tenant === null) {
    return 403; // Central trying to access tenant route
}

// Otherwise, allow request

Database Connection Management

TenantConnectionFactory manages database connections based on tenant strategy.

Connection Factory

TenantConnectionFactory creates connections based on strategy:

use App\Modules\ForgeMultiTenant\Services\TenantConnectionFactory;

$connection = $factory->forTenant($tenant);

// Returns appropriate connection based on strategy:
// - COLUMN: Reuses same connection (cached)
// - VIEW: Same connection with SET app.tenant
// - DB: New connection to tenant database (cached)

COLUMN Strategy Connection

  • Reuses the default database connection
  • Connection is cached per tenant
  • Query rewriting handles tenant isolation
  • No connection changes needed

VIEW Strategy Connection

  • Uses the same database connection
  • Executes SET app.tenant = 'tenant-id'
  • Database views filter based on app.tenant variable
  • Connection reused, variable set per request

DB Strategy Connection

  • Creates new connection to tenant database
  • Connection is cached per tenant
  • SQLite: Creates file at storage/Database/{dbName}.sqlite
  • MySQL/PostgreSQL: Connects to {dbName} database
// DB Strategy connection creation:
if (driver === 'sqlite' && dbName !== null) {
    $dbFile = BASE_PATH . '/storage/Database/' . $dbName . '.sqlite';
    if (!file_exists($dbFile)) {
        touch($dbFile);  // Create file if doesn't exist
    }
    // Create connection to SQLite file
} else {
    // MySQL/PostgreSQL: Connect to {dbName} database
}

Connection Caching

Connections are cached per tenant for performance:

  • COLUMN strategy: Cached in container with key tenant.conn.{tenant-id}
  • DB strategy: Connections cached per tenant
  • Reduces connection overhead
  • Container manages connection lifecycle

Query Rewriting

TenantQueryRewriter automatically adds tenant_id filtering to queries for COLUMN strategy.

Automatic Query Filtering

TenantQueryRewriter injects WHERE clauses:

// Original query:
$builder->table('posts')->where('status', '=', 'active')->get();

// After TenantQueryRewriter (COLUMN strategy):
// WHERE tenant_id = 'current-tenant-id' AND status = 'active'

// Automatically scoped to current tenant

How It Works

  • TenantScopedTrait overrides newQuery() method
  • Calls TenantQueryRewriter::scope()
  • TenantQueryRewriter checks if COLUMN strategy
  • Adds WHERE tenant_id = ? clause
  • Only active for COLUMN strategy

TenantQueryRewriter API

use App\Modules\ForgeMultiTenant\Services\TenantQueryRewriter;

// Set current tenant (done by TenantMiddleware)
TenantQueryRewriter::setTenant($tenant);

// Scope a query builder
$scopedBuilder = TenantQueryRewriter::scope($builder);

// Only adds WHERE clause if:
// - Tenant is set
// - Strategy is COLUMN

CLI Commands

ForgeMultiTenant provides CLI commands for managing tenants and running tenant-specific migrations and seeders.

tenant:list

List all configured tenants:

php forge.php tenant:list

# Output:
# +----+------------------+-------------+----------+
# | ID | Domain           | Sub-Domain  | Strategy |
# +----+------------------+-------------+----------+
# | central | forge-v3.test | -          | column   |
# | my-tenant | forge-v3.test | my-tenant | column   |
# +----+------------------+-------------+----------+

tenant:migrate

Run migrations for one or all tenants:

# Run migrations for all tenants
php forge.php tenant:migrate

# Run migrations for specific tenant
php forge.php tenant:migrate --tenant=my-tenant

# Preview migrations without executing
php forge.php tenant:migrate --preview

# Preview for specific tenant
php forge.php tenant:migrate --tenant=my-tenant --preview

Migrations are run from app/Database/Migrations/Tenants/ directory.

tenant:seed

Run seeders for one or all tenants:

# Run seeders for all tenants
php forge.php tenant:seed

# Run seeders for specific tenant
php forge.php tenant:seed --tenant=my-tenant

# Preview seeders without executing
php forge.php tenant:seed --preview

# Preview for specific tenant
php forge.php tenant:seed --tenant=my-tenant --preview

Seeders are run from app/Database/Seeders/Tenants/ directory.

How Tenant Commands Work

  • Commands iterate over specified tenants
  • For each tenant, create tenant-specific connection
  • Set connection on Migrator/SeederManager
  • Run migrations/seeders with tenant connection
  • Each tenant gets its own migration/seed execution

Configuration

ForgeMultiTenant is configured via environment variables and database records.

Environment Variables

# Central domain (admin/central functionality)
CENTRAL_DOMAIN=forge-v3.test

Tenant Configuration

Tenants are configured in the tenants table:

// Example tenant records:
[
    'id' => 'central',
    'domain' => 'forge-v3.test',
    'subdomain' => null,
    'strategy' => 'column',
    'db_name' => null,
    'connection' => null
],
[
    'id' => 'my-tenant',
    'domain' => 'forge-v3.test',
    'subdomain' => 'my-tenant',
    'strategy' => 'column',
    'db_name' => null,
    'connection' => null
],
[
    'id' => 'enterprise',
    'domain' => 'enterprise.com',
    'subdomain' => null,
    'strategy' => 'database',
    'db_name' => 'enterprise_db',
    'connection' => null
]

Strategy Selection

Choose strategy based on requirements:

  • COLUMN: Set strategy = 'column', db_name = null
  • VIEW: Set strategy = 'view', db_name = null
  • DB: Set strategy = 'database', db_name = 'tenant_db_name'

Database Schema

The tenants table stores tenant configuration.

Table Structure

CREATE TABLE tenants (
    id VARCHAR(36) PRIMARY KEY,
    domain VARCHAR(255) NOT NULL,
    subdomain VARCHAR(255) NULL,
    strategy VARCHAR(20) DEFAULT 'column',
    db_name VARCHAR(64) NULL,
    connection VARCHAR(64) NULL,
    metadata JSON NULL,
    created_at TIMESTAMP,
    updated_at TIMESTAMP,
    deleted_at TIMESTAMP NULL
);

Columns

  • id: Unique tenant identifier (primary key)
  • domain: Base domain for tenant
  • subdomain: Optional subdomain (null for full domain)
  • strategy: Multi-tenancy strategy ('column', 'view', 'database')
  • db_name: Database name (required for DB strategy)
  • connection: Connection name (optional, for future use)
  • metadata: JSON metadata (optional)
  • created_at, updated_at: Timestamps
  • deleted_at: Soft delete timestamp

Indexes

-- Index for domain lookups
CREATE INDEX idx_tenants_domain ON tenants(domain);

-- Index for subdomain lookups
CREATE INDEX idx_tenants_subdomain ON tenants(subdomain);

Usage Examples

Comprehensive examples demonstrating ForgeMultiTenant usage.

Tenant-Scoped Model

use App\Modules\ForgeMultiTenant\Attributes\TenantScoped;
use App\Modules\ForgeMultiTenant\Traits\TenantScopedTrait;
use App\Modules\ForgeSqlOrm\ORM\Model;

#[TenantScoped]
#[Table("posts")]
class Post extends Model
{
    use HasTimeStamps;
    use HasMetaData;
    use TenantScopedTrait;

    #[Column(primary: true, cast: Cast::STRING)]
    public int $id;

    #[Column(cast: Cast::STRING)]
    public string $title;

    #[Column(cast: Cast::STRING)]
    public string $content;

    #[Column(cast: Cast::STRING)]
    public string $tenant_id;  // Required for COLUMN strategy
}

Controller with Route Scoping

use App\Modules\ForgeMultiTenant\Attributes\TenantScope;

#[TenantScope("central")]
final class HomeController
{
    // All routes in this controller are central-only
    // Accessible only from central domain (forge-v3.test)
    
    #[Route("/")]
    public function index(): Response
    {
        // Central domain functionality
        return $this->view("pages/home/index");
    }
}

Tenant Migration

use App\Modules\ForgeDatabaseSQL\DB\Attributes\GroupMigration;
use App\Modules\ForgeDatabaseSQL\DB\Migrations\Migration;

#[GroupMigration('tenants')]
#[Table(name: 'users')]
class CreateTenantUsersTable extends Migration
{
    #[Column(name: 'id', type: ColumnType::INTEGER, primaryKey: true)]
    public readonly int $id;

    #[Column(name: 'email', type: ColumnType::STRING, unique: true)]
    public readonly string $email;

    // This migration runs for each tenant
    // when executing: php forge.php tenant:migrate
}

Accessing Current Tenant

use function App\Modules\ForgeMultiTenant\helpers\tenant;

// Get current tenant
$tenant = tenant();

if ($tenant) {
    echo "Tenant ID: {$tenant->id}";
    echo "Domain: {$tenant->domain}";
    echo "Strategy: {$tenant->strategy->value}";
} else {
    echo "No tenant (central domain)";
}

CLI Command Examples

# List all tenants
php forge.php tenant:list

# Run migrations for all tenants
php forge.php tenant:migrate

# Run migrations for specific tenant
php forge.php tenant:migrate --tenant=my-tenant

# Preview migrations
php forge.php tenant:migrate --preview

# Run seeders for all tenants
php forge.php tenant:seed

# Run seeders for specific tenant
php forge.php tenant:seed --tenant=my-tenant

Best Practices

Guidelines for using ForgeMultiTenant effectively.

Strategy Selection

  • COLUMN: Use for many small tenants, cost-effective, acceptable isolation
  • VIEW: Use when better isolation needed without separate databases
  • DB: Use for high-security requirements, compliance, large tenants
  • You can mix strategies - different tenants can use different strategies

Domain/Subdomain Organization

  • Use subdomains for SaaS applications (tenant1.example.com)
  • Use full domains for enterprise customers (enterprise.com)
  • Keep central domain separate for admin functionality
  • Configure DNS and web server for domain routing

Central vs Tenant Route Design

  • Use #[TenantScope("central")] for admin/management routes
  • Use #[TenantScope("tenant")] for tenant-specific routes
  • Leave routes unscoped for shared functionality
  • Design clear separation between central and tenant features

Migration and Seeding Strategies

  • Use tenant migrations for tenant-specific schema changes
  • Use tenant seeders for tenant-specific data
  • Test migrations with --preview before running
  • Run migrations for all tenants when adding new tenants
  • Keep tenant migrations in app/Database/Migrations/Tenants/

Performance Considerations

  • COLUMN strategy is most performant for many tenants
  • DB strategy has connection overhead per tenant
  • Connection caching reduces overhead
  • Index tenant_id column for COLUMN strategy
  • Monitor query performance per strategy

Security Considerations

  • Always use tenant-scoped models for COLUMN strategy
  • Verify tenant isolation in queries
  • Use DB strategy for high-security requirements
  • Validate tenant access in controllers
  • Never trust client-provided tenant IDs