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.
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.
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.
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.
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.
Choose how strongly each tenant's data is isolated. There are three strategies, and you can mix them — assign whichever fits each tenant:
tenant_id column. Queries are
scoped automatically. The simplest to run,
most shared infrastructure.
The strategy is recorded per tenant, so a single deployment can host a mix of them and evolve tenants over time.
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.
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.
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.
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.
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.
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.