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
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