Shared-DB Driver¶
In shared-DB mode, all tenants share one database. A Doctrine SQL filter (TenantAwareFilter)
automatically appends WHERE tenant_id = '<slug>' to every query for entities marked with the
#[TenantAware] attribute. Simpler to operate than database-per-tenant — one database, one set
of migrations — but with less physical isolation.
Overview¶
- One database, multiple tenants
- Doctrine SQL filter scopes all queries for
#[TenantAware]entities automatically - No DBAL connection switching — the filter operates at the SQL level
- Works with any DBAL-supported database (MySQL, PostgreSQL, SQLite)
Configuration¶
Never combine shared_db with database.enabled: true
Setting both driver: shared_db AND database.enabled: true is rejected at compile time
with a clear error. These are mutually exclusive isolation strategies — pick one. The shared-DB
driver uses the default entity manager; no tenant EM is needed.
Marking Entities as Tenant-Aware¶
Add the #[TenantAware] attribute to any Doctrine entity that should be scoped per tenant. The
entity must have a tenant_id VARCHAR(63) column:
<?php
declare(strict_types=1);
namespace App\Entity;
use Doctrine\ORM\Mapping as ORM;
use Tenancy\Bundle\Attribute\TenantAware;
#[ORM\Entity(repositoryClass: InvoiceRepository::class)]
#[ORM\Table(name: 'invoices')]
#[TenantAware]
class Invoice
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\Column(length: 63)]
private string $tenantId;
#[ORM\Column(type: 'decimal', precision: 10, scale: 2)]
private string $amount;
#[ORM\Column(length: 255)]
private string $description;
// ... getters / setters
}
The tenant_id column stores the tenant's slug. You are responsible for setting it when creating
entities:
$invoice = new Invoice();
$invoice->setTenantId($tenantContext->getTenant()->getSlug());
$invoice->setAmount('99.99');
$invoice->setDescription('Pro plan subscription');
$em->persist($invoice);
$em->flush();
How the SQL Filter Works¶
When a request boots the tenant context, SharedDriver::boot() injects TenantContext into the
TenantAwareFilter instance. For every query involving an entity class, Doctrine calls
TenantAwareFilter::addFilterConstraint():
Entity has #[TenantAware]?
No → return '' (no constraint — entity is unscoped)
Yes → Is a tenant active?
No + strict_mode → throw TenantMissingException
No + permissive → return '' (returns all rows — dangerous!)
Yes → return "{alias}.tenant_id = '<slug>'"
The resulting SQL looks like:
-- Without filter
SELECT i.* FROM invoices i WHERE i.status = 'pending';
-- With TenantAwareFilter (tenant slug = 'acme')
SELECT i.* FROM invoices i WHERE i.status = 'pending' AND i.tenant_id = 'acme';
The filter is automatically registered in Doctrine via prependExtension when driver: shared_db
is configured — no manual Doctrine filter configuration required.
Strict Mode¶
With strict_mode: true (default), querying a #[TenantAware] entity without an active tenant
throws TenantMissingException. This prevents accidental full-table scans in console commands or
background jobs that run without tenant context.
Disable strict mode with caution
Setting strict_mode: false makes the filter return all rows when no tenant is active. Any
console command, async job, or code path that runs without a resolved tenant will silently
return data from all tenants. This is a data leak. Only disable strict mode if every
unguarded code path is explicitly safe to handle cross-tenant data.
See Strict Mode for strategies to handle non-tenant code paths.
Mixed Entities¶
Entities without #[TenantAware] are completely unaffected by the filter. They return full
result sets regardless of tenant context. Use this for genuinely shared data:
// No #[TenantAware] — shared across all tenants
#[ORM\Entity]
class Country
{
#[ORM\Id]
#[ORM\Column(length: 2)]
private string $code;
#[ORM\Column(length: 100)]
private string $name;
}
This is useful for lookup tables, user profiles (if users span tenants), or any global data.
Inheritance Hierarchies¶
In Single Table Inheritance (STI) or Joined Table Inheritance (JTI), place #[TenantAware] on
the root entity only. Doctrine passes root entity metadata to addFilterConstraint(), so
child entities are automatically scoped.
#[ORM\Entity]
#[ORM\InheritanceType('SINGLE_TABLE')]
#[ORM\DiscriminatorColumn(name: 'type')]
#[TenantAware] // <-- on root only
class Document { ... }
#[ORM\Entity]
class Invoice extends Document { ... } // inherits tenant scoping
#[ORM\Entity]
class Receipt extends Document { ... } // inherits tenant scoping
Shared Entities (#[Shared]) Under shared_db¶
The #[Shared] attribute and SharedEntitySyncSubscriber are designed for the
database-per-tenant driver, where each tenant has its own EntityManager. In that model a
record is a landlord-side master on the landlord EM and each tenant receives a
tenant-side read-only copy fanned out by the subscriber. See Shared Entities
for the full fan-out documentation.
Under shared_db, all tenants already share the same database and the same default EntityManager —
there is nothing to fan out to and the landlord-side master / tenant-side read-only copy split
does not apply.
SharedEntitySyncSubscriber detects driver: shared_db at runtime and short-circuits
immediately in postFlush(). No findAll() is called, no tenant EM is looked up, and no
secondary flush() is issued. Placing #[Shared] on an entity under shared_db is
silently harmless: the attribute is ignored and the subscriber no-ops.
No-op is intentional
If you later migrate from shared_db to database_per_tenant, add #[Shared] to your
global-data entities and the subscriber will automatically start fanning out changes to every
tenant database — no other code changes required.
The mutual-exclusion guard only inspects manually tagged classes
The compile-time guard (SharedEntityMutualExclusionPass) rejects entities that carry both
#[Shared] and #[TenantAware] simultaneously (a contradictory, data-leak-prone combination).
It is not automatic. Doctrine entities are plain mapped classes, never registered as
container services, so the guard has nothing to inspect unless you explicitly register each
shared-entity class as a service tagged tenancy.shared_entity:
Without that tag the guard is a no-op — it will not catch a class that accidentally carries
both attributes. The guard does walk the class hierarchy, so a #[Shared]/#[TenantAware]
inherited from a parent or mapped-superclass on a tagged class is detected. Editor-time
detection that does not require manual tagging is planned as a PHPStan rule (Phase 28 / DX-03).
See Also¶
- Database-per-Tenant Driver — maximum isolation with separate databases
- Strict Mode — handling non-tenant contexts safely
- Examples: API Header — shared-DB with REST API example