ForgeMultiTenant

A multi-tenancy component your app uses to serve many customers from one codebase — identify which tenant a request belongs to, keep each tenant's data separate, and scope queries without leaking data between them.

Overview

ForgeMultiTenant is a component that brings multi-tenancy to your app. Each tenant is registered with an identity (a domain and optional subdomain) and a separation strategy. On every request the component works out which tenant is being addressed, makes that tenant the active context, and — depending on the strategy — scopes data or switches database connections so one tenant can't see another's records.

What it gives you

  • Request-based tenant resolution
  • Three isolation strategies
  • Query scoping with a trait
  • Per-tenant database connections
  • Tenant-aware cache & sessions
  • CLI to migrate / seed tenants

Prerequisites: ForgeMultiTenant requires ForgeDatabaseSQL (for the tenant registry and data) and the routing layer, which it extends with middleware. Read ForgeSqlOrm for the model layer you'll scope.

When to Reach for It

Use ForgeMultiTenant when multiple independent groups need their own data in one deployment — a SaaS where each customer is a tenant, or a product that hosts many organizations. The component answers "which tenant is this?" and keeps their worlds apart, so your business code just works against the current tenant.

If everything is a single shared space with no isolation boundary, you probably don't need it.

Installation

php forge.php package:install-module --module=ForgeMultiTenant

Installing creates the tenants table, seeds it, and runs the migrations on all existing tenants. It also registers the middleware that does the per-request tenant resolution.

Tenants are stored in a central tenants table keyed by a 36-character id, with a domain (indexed), an optional subdomain (indexed), the isolation strategy, and optional database name and connection overrides.

Strategies

Choose how strongly each tenant's data is isolated. There are three strategies, and you can mix them — assign whichever fits each tenant:

  • Column — all tenants share one set of tables; each table carries a tenant_id column. Queries are scoped automatically. The simplest to run, most shared infrastructure.
  • View — tenants share the backing tables but read through views that present just their slice. Useful when tenants share base data but see it differently.
  • Database — each tenant gets its own database, switched per request. The strongest isolation, with per-tenant credentials.

The strategy is recorded per tenant, so a single deployment can host a mix of them and evolve tenants over time.

How a Tenant Resolves

For each request, the component looks at the host and checks the tenant registry. A request to acme.forge.localhost resolves the tenant whose domain/subdomain matches. Requests to your central domain — and to local hosts while you develop — aren't treated as a tenant; that's where you manage tenants and run the app's admin.

If a host doesn't match a known tenant, the component serves your configured unknown_tenant page or view. Otherwise a request that resolves a tenant gets its connection, session, and cache swapped to the tenant-aware versions for the duration of the request.

You resolve a tenant programmatically with tenant() — it hands back the current tenant (domain, subdomain, strategy, and so on) or null on the central domain.

Working With Tenants

Small helpers give your code a clean read on the active tenant:

$current = tenant();          // ?Tenant — the active tenant or null

$tenantId = get_tenant_id();  // ?string — just the id

$tenant = requireTenant();    // Tenant — throws if there is none

$url = tenant_url();          // e.g. https://acme.forge.localhost

The registry is cached per request; call TenantManager::clearCache() when tenants change and they'll be re-read on the next resolution. all() and find($id) let you iterate the registry when you need to run something across every tenant.

Scoping Data

With the column strategy, keep tenant data apart by scoping your model queries. Apply the trait to a model and its queries are rewritten to include the current tenant:

use Modules\ForgeMultiTenant\Traits\TenantScopedTrait;

final class Invoice extends Model
{
    use TenantScopedTrait;
}

Underneath, the model's fresh queries flow through the TenantQueryRewriter, which adds the tenant constraint so the current tenant only sees its own rows. Tables that should carry the tenant_id column are set up with the provided schema helper, which adds the column for column-strategy tenants. The TenantScoped marker attribute flags a class as tenant-scoped.

Central vs. Tenant Routes

Some routes only make sense on the central domain (manage tenants, app admin), while others only make sense inside a tenant. Mark a controller or handler with the TenantScope attribute — central or tenant — and the scope middleware enforces it, redirecting or blocking mismatches:

use Modules\ForgeMultiTenant\Attributes\TenantScope;

#[TenantScope(TenantScope::TENANT)]
final class AccountController
{
    // only reachable inside a tenant
}

This keeps tenant-only features from being hit on the central domain (and vice versa), so your business logic can assume the right context.

CLI Commands

Manage your tenants' schemas and data from the command line. Each command operates on all tenants by default, or a single one:

# List every tenant
php forge.php modules:tenant:list

# Run migrations for all tenants (or one)
php forge.php modules:tenant:migrate
php forge.php modules:tenant:migrate --tenant=acme-id

# Seed all tenants (or one) — dry-run with --preview
php forge.php modules:tenant:seed --preview

Running migrations or seeders across tenants is a common onboarding step when you add a new tenant to an existing deployment.

Configuration

A few environment variables shape the behavior — all optional with sensible defaults:

# The domain you run the app's admin / tenant management on
FORGE_MULTI_TENANT_CENTRAL_DOMAIN=forge.localhost

# What to serve when a host doesn't match a tenant
FORGE_MULTI_TENANT_UNKNOWN_PAGE=
FORGE_MULTI_TENANT_UNKNOWN_VIEW=

Tenants themselves aren't configured in code here — you register them as rows in the tenants table (via your app's own tenant management), each with its id, domain, subdomain, and strategy.