Multi-tenancy support for Forge Kernel. Provides multiple database strategies, domain-based tenant identification, automatic tenant scoping, and tenant-specific migrations and seeders.
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.
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.
ForgeMultiTenant is built with flexibility, performance, and security in mind.
ForgeMultiTenant supports three common multi-tenancy patterns:
Each tenant can use a different strategy:
Tenants are identified automatically from the request:
Database connections are managed automatically:
ForgeMultiTenant can be installed via 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
ForgeMultiTenant requires:
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)
Set the central domain in your environment:
CENTRAL_DOMAIN=forge-v3.test
ForgeMultiTenant supports three database strategies, each with different isolation levels and use cases.
Same database, tenant_id column for isolation:
tenant_id columntenant_id// Tenant configuration
[
'id' => 'tenant-1',
'domain' => 'example.com',
'subdomain' => 'tenant1',
'strategy' => 'column',
'db_name' => null, // Uses default database
'connection' => null
]
Same database, database views for isolation:
SET app.tenant variable for view filtering// 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'
Separate database per tenant:
// 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 | Isolation | Cost | Use Case |
|---|---|---|---|
| COLUMN | Low | Lowest | Many small tenants |
| VIEW | Medium | Low | Better isolation needed |
| DB | High | Higher | High security, compliance |
Tenants are automatically identified from the request's Host header.
Tenants are resolved by matching the request domain:
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
Tenants can also use full domains:
// Tenant configuration
[
'id' => 'enterprise-tenant',
'domain' => 'enterprise.com',
'subdomain' => null, // No subdomain
'strategy' => 'database'
]
// Resolves from: enterprise.com
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 hosts are handled gracefully:
Tenants are represented by the Tenant DTO and managed by TenantManager.
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 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');
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}";
}
Models can be automatically scoped to the current tenant using the #[TenantScoped] attribute and TenantScopedTrait.
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
}
// 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
tenant_id column (for COLUMN strategy)Controllers and routes can be scoped to central or tenant contexts using the #[TenantScope] attribute.
use App\Modules\ForgeMultiTenant\Attributes\TenantScope;
#[TenantScope("central")]
final class HomeController
{
// All routes in this controller are central-only
// Not accessible from tenant domains
}
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
}
}
ScopeMiddleware automatically enforces route scoping:
ForgeMultiTenant provides two middleware for tenant resolution and route scoping.
Resolves tenant from domain and sets up tenant-specific 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
Enforces route scoping based on #[TenantScope] attributes:
// 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
TenantConnectionFactory manages database connections based on tenant strategy.
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)
SET app.tenant = 'tenant-id'storage/Database/{dbName}.sqlite{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
}
Connections are cached per tenant for performance:
tenant.conn.{tenant-id}TenantQueryRewriter automatically adds tenant_id filtering to queries for COLUMN strategy.
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
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
ForgeMultiTenant provides CLI commands for managing tenants and running tenant-specific migrations and seeders.
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 |
# +----+------------------+-------------+----------+
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.
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.
ForgeMultiTenant is configured via environment variables and database records.
# Central domain (admin/central functionality)
CENTRAL_DOMAIN=forge-v3.test
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
]
Choose strategy based on requirements:
strategy = 'column', db_name = nullstrategy = 'view', db_name = nullstrategy = 'database', db_name = 'tenant_db_name'
The tenants table stores tenant configuration.
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
);
-- Index for domain lookups
CREATE INDEX idx_tenants_domain ON tenants(domain);
-- Index for subdomain lookups
CREATE INDEX idx_tenants_subdomain ON tenants(subdomain);
Comprehensive examples demonstrating ForgeMultiTenant usage.
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
}
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");
}
}
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
}
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)";
}
# 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
Guidelines for using ForgeMultiTenant effectively.