Getting Started¶
This guide walks you from a freshly installed bundle to a working tenant-resolved request in under 5 minutes. Choose your isolation driver below and follow the corresponding quick path.
Prerequisites¶
- Bundle installed (see Installation)
- Doctrine ORM configured (for database isolation drivers)
- At least one DBAL connection configured in
config/packages/doctrine.yaml
Choose Your Driver¶
| Driver | Isolation | Best For |
|---|---|---|
database_per_tenant |
Each tenant gets its own database | Maximum isolation; regulatory requirements; large tenants |
shared_db |
One database, SQL filter scopes queries | Simpler ops; small-to-medium tenants; hosting constraints |
Not sure which to choose?
Start with database_per_tenant if you have the operational capacity — it provides true data isolation at the database level. Use shared_db if you need to keep all tenant data in a single schema.
Path A: Database-per-Tenant¶
Step 1 — Configure tenancy.yaml¶
# config/packages/tenancy.yaml
tenancy:
driver: database_per_tenant
database:
enabled: true
host:
app_domain: example.com
Step 2 — Configure Doctrine with Two Entity Managers¶
The bundle requires a landlord entity manager for its own Tenant entity, and a
tenant entity manager for application data. When tenancy.database.enabled: true, the
bundle automatically registers its TenantDriverMiddleware on the tenant connection via
the doctrine.middleware tag — no additional Doctrine configuration is required.
# config/packages/doctrine.yaml (example for MySQL tenants)
doctrine:
dbal:
connections:
default:
url: '%env(DATABASE_URL)%'
tenant:
# Driver family MUST match your tenant databases (see warning below).
# Params below are merged with the active tenant's getConnectionConfig()
# at connect() time by TenantDriverMiddleware.
driver: pdo_mysql
host: '%env(TENANT_DB_HOST)%'
user: '%env(TENANT_DB_USER)%'
password: '%env(TENANT_DB_PASSWORD)%'
dbname: placeholder_tenant
orm:
entity_managers:
landlord:
connection: default
# Tenancy\Bundle\Entity\Tenant mapping is prepended automatically
default:
connection: tenant
mappings:
App:
is_bundle: false
type: attribute
dir: '%kernel.project_dir%/src/Entity'
prefix: App\Entity
alias: App
Entity manager naming
The landlord EM must be named landlord. The tenant EM is your default EM (named
default). The bundle rewires DoctrineTenantProvider to the landlord EM
automatically when database.enabled: true.
Driver family must match
The tenant connection's driver parameter MUST match the driver family of your actual
tenant databases. Use pdo_mysql for MySQL tenants, pdo_pgsql for PostgreSQL,
pdo_sqlite for SQLite (testing). The middleware merges tenant params at connect()
time, but the driver itself is resolved from the placeholder at container boot.
Step 3 — Create the Tenant Record¶
The bundle ships with a Tenant entity stored in the tenancy_tenants table of the landlord database. Run migrations to create this table:
Then create your first tenant. You can use a fixture, a command, or a data fixture:
<?php
declare(strict_types=1);
use Tenancy\Bundle\Entity\Tenant;
$tenant = new Tenant('acme', 'Acme Corporation');
$tenant->setConnectionConfig([
'driver' => 'pdo_mysql',
'host' => 'localhost',
'port' => 3306,
'dbname' => 'tenant_acme',
'user' => 'acme_user',
'password' => 'secret',
]);
$landlordEm->persist($tenant);
$landlordEm->flush();
The Tenant entity fields:
| Field | Type | Description |
|---|---|---|
slug |
string (PK, max 63) |
URL-safe identifier — used in subdomains and headers |
name |
string |
Human-readable display name |
domain |
string\|null |
Custom domain (e.g. acme.com) — optional |
connectionConfig |
array (JSON) |
DBAL connection parameters for this tenant's database |
isActive |
bool |
When false, requests from this tenant throw TenantInactiveException |
createdAt |
DateTimeImmutable |
Set via #[PrePersist] |
updatedAt |
DateTimeImmutable |
Set via #[PreUpdate] |
Step 4 — Make a Request¶
With app_domain: example.com configured, a request to acme.example.com is automatically resolved:
TenantContextOrchestratorfires atkernel.requestpriority 20HostResolverextracts slugacmefrom the subdomainDoctrineTenantProviderloads theTenantentity from the landlord DBTenantResolvedevent firesDatabaseSwitchBootstrappercalls$connection->close()on the tenant DBAL connection. On the next query, DBAL's lazy reconnect routes throughTenantAwareDriver::connect(), which mergesacme'sgetConnectionConfig()over the placeholder params and opens a socket totenant_acme.- Your controller runs — all Doctrine queries go to
tenant_acme
Path B: Shared-DB¶
Step 1 — Configure tenancy.yaml¶
shared_db and database.enabled are mutually exclusive
Setting both driver: shared_db and database.enabled: true is rejected at container compile time with a clear error message. These are two different isolation strategies — you must choose one.
Step 2 — Configure Doctrine¶
In shared-DB mode you only need a single connection and entity manager. The bundle registers the tenancy_aware Doctrine SQL filter automatically:
# config/packages/doctrine.yaml
doctrine:
dbal:
url: '%env(DATABASE_URL)%'
orm:
auto_mapping: true
# The bundle prepends the tenancy_aware filter automatically:
# filters:
# tenancy_aware:
# class: Tenancy\Bundle\Filter\TenantAwareFilter
# enabled: true
Step 3 — Mark Entities with #[TenantAware]¶
Add the #[TenantAware] attribute to any entity that belongs to a tenant. The SQL filter automatically appends a WHERE tenant_id = '<slug>' clause to all queries for these entities:
<?php
declare(strict_types=1);
namespace App\Entity;
use Doctrine\ORM\Mapping as ORM;
use Tenancy\Bundle\Attribute\TenantAware;
#[ORM\Entity]
#[TenantAware]
class Invoice
{
#[ORM\Id, ORM\GeneratedValue, ORM\Column]
private ?int $id = null;
#[ORM\Column]
private string $tenantId;
// ... other fields
}
tenant_id column required
Every #[TenantAware] entity must have a tenant_id column. The SQL filter generates WHERE alias.tenant_id = '<slug>' — if the column does not exist, queries will fail at runtime.
Step 4 — Make a Request¶
A request to acme.example.com follows the same resolution path. Instead of switching connections, SharedDriver::boot() injects the active TenantContext into TenantAwareFilter. From that point on, every Doctrine query for a #[TenantAware] entity is automatically scoped to acme.
What Happens on Every Request¶
HTTP Request
|
kernel.request (priority 20)
|
TenantContextOrchestrator
|
ResolverChain.resolve()
├── HostResolver (priority 30) ← acme.example.com → slug: acme
├── HeaderResolver (priority 20) ← X-Tenant-ID: acme
├── QueryParamResolver (priority 10) ← ?_tenant=acme
└── (no match → ResolverChain returns null → request proceeds with no tenant)
|
TenantResolved event
|
BootstrapperChain.boot()
├── DatabaseSwitchBootstrapper (database_per_tenant mode)
│ └── $connection->close() (next query re-enters TenantAwareDriver::connect())
├── SharedDriver.boot() (shared_db mode)
│ └── TenantAwareFilter::setTenantContext(...)
└── DoctrineBootstrapper
└── EntityManager::clear() (prevent identity map cross-tenant pollution)
|
TenantBootstrapped event
|
Controller / Handler runs
|
kernel.terminate
|
TenantContextCleared event
|
BootstrapperChain.clear() (reverse order)
Next Steps¶
- Configuration Reference — every
tenancy.yamlkey with types and defaults - Resolvers — configure and extend the resolver chain
- Database-per-Tenant — deep dive into the DBAL wrapper mechanics
- Shared-DB Driver — deep dive into the SQL filter approach