Multi-tenancy for Symfony. Zero boilerplate, zero leaks.
Documentation · Runnable demo · Changelog · Roadmap · Upgrade guide
Resolve a tenant once at the edge of the request — every Symfony subsystem reconfigures itself for the rest of the lifecycle.
- The DBAL connection switches to the tenant's database (or the Doctrine SQL filter scopes every query)
- Cache pools namespace by tenant
- The Mailer transport swaps to the tenant's SMTP/DSN
- Messenger envelopes stamp the active tenant and re-boot it on the consumer side
- Your code stays tenant-unaware:
// Controller — no $tenantId parameter, no manual filtering, no leaks
public function index(InvoiceRepository $repo): Response
{
return $this->render('invoice/index.html.twig', [
'invoices' => $repo->findAll(), // automatically scoped to the active tenant
]);
}That's it. The event-driven kernel extension does the rest.
Laravel has stancl/tenancy. Symfony users have been writing their own glue for years — manual $tenantId parameters, leaked queries discovered in production, half-built abstractions that don't compose with Doctrine + Messenger + Cache + Mailer at the same time. This bundle treats tenancy as a first-class kernel extension, not a database switcher bolted on top.
- PHPStan level 9 clean — no
@phpstan-ignore, nomixedshortcuts - 559 tests / 2,068 assertions across the unit + integration suites
- CI matrix: PHP 8.2 / 8.3 / 8.4 × Symfony 7.4 / 8.0, plus
prefer-lowest, "No Doctrine", and "No Messenger" guard builds demo-smokelive-stack gate: every push tomasterrebuilds the three-tenant FrankenPHP + Caddy + MariaDB demo and exercises tenant isolation end-to-end viabin/smoke.sh(~90s)- ASVS-L1 threat model per phase, security gate before phase verification
- Strict mode on by default — a missing tenant on a
#[TenantAware]entity is an exception, not silent data leakage
composer require danplaton4/tenancy-bundleRegister the bundle in config/bundles.php, then run bin/console tenancy:init to scaffold config/packages/tenancy.yaml.
One-shot setup:
bin/console tenancy:installhandles both steps in a single command. It usesnikic/php-parserto AST-editconfig/bundles.phpsafely — install as a dev dependency first:composer require --dev nikic/php-parser. Without it the command exits 1 with a clear error and prints the manual snippet to paste.
Configure (config/packages/tenancy.yaml):
tenancy:
driver: database_per_tenant # or shared_db
database:
enabled: trueMark tenant-scoped entities (shared-DB mode only):
use Tenancy\Bundle\Attribute\TenantAware;
#[ORM\Entity]
#[TenantAware]
class Invoice { /* ... */ }That's the minimum. See the Documentation for resolver options, custom bootstrappers, Messenger integration, testing, and the contributor guide.
A runnable three-tenant Symfony app lives under examples/saas/:
git clone https://github.com/danplaton4/tenancy-bundle.git
cd tenancy-bundle/examples/saas
docker compose up -d --wait --build # ~30s warm, ~110s cold
open http://acme.tenancy.localhost/ # or curl -H 'Host: acme.tenancy.localhost' http://localhost/Three tenants (acme, globex, initech) + a landlord page, FrankenPHP + Caddy + MariaDB 11, with the Profiler tab and Mailpit always-up. If host ports 80 / 8025 are already taken on your machine, override:
PORT_HTTP=8081 PORT_MAILPIT_UI=8026 docker compose up -d --wait --build
BASE_PORT=8081 bash bin/smoke.sh # DNS-independent isolation proofSee examples/saas/README.md for the full walkthrough — three-step fallback ladder (curl Host: → /etc/hosts → browser-native *.localhost), Mailpit + Profiler walkthroughs, CI gate details.
- Database-per-tenant — DBAL connection switches at runtime per tenant via
TenantDriverMiddleware, nowrapper_classconfig required - Shared-database — Doctrine SQL filter with
#[TenantAware]attribute; zero manual query scoping; strict-mode by default - 5 built-in resolvers — Host (subdomain), Origin header (SPA-friendly, priority 25, allow-listed),
X-Tenant-IDheader, query param, CLI--tenantflag. Chain in any order via config; add your own. - Cache namespace isolation — per-tenant cache pool prefixing, no cross-tenant bleed
- Mailer bootstrapper — per-tenant SMTP DSN +
From+Reply-Toheaders, sync + async safe viaX-Transportstrategy - Messenger context propagation —
TenantStampattached to every envelope, re-booted on consume; works under sync and async transports - Symfony Profiler tab — "Tenancy" panel in the WDT showing slug, label, driver, resolver, bootstrappers, error state. Auto-registered when
kernel.debug=true, compile-stripped in prod - CLI commands —
tenancy:install(one-shot setup),tenancy:init(scaffold config),tenancy:migrate(run migrations per tenant),tenancy:run(wrap any command with tenant context) - PHPUnit testing trait —
InteractsWithTenancysets up a clean tenant DB/schema per test method, real SQLite, no mocks - Custom entity support — extend
AbstractTenant(MappedSuperclass) to add columns likebrandColor,plan,billingIdwithout breaking Doctrine inheritance
The bundle hooks into the Symfony kernel via a kernel.request listener at priority 20 (above Security at 8, below Router at 32). A resolver chain identifies the tenant from the request. Once resolved, BootstrapperChain runs every registered bootstrapper to reconfigure its subsystem. On kernel.terminate, tenant context is cleared.
Request → Router → TenantContextOrchestrator (priority 20)
│
ResolverChain
(Host / Origin / Header / QueryParam / Console)
│
TenantResolved event
│
BootstrapperChain.boot()
├─ DatabaseSwitchBootstrapper
├─ DoctrineBootstrapper
├─ CacheBootstrapper
└─ MailerBootstrapper
│
TenantBootstrapped event
│
Controller runs
│
kernel.terminate
│
TenantContextCleared event
Bootstrappers are Symfony services tagged with tenancy.bootstrapper — add your own by implementing TenantBootstrapperInterface and tagging the service. No bundle internals to modify. See the Custom Bootstrapper guide.
| Feature | danplaton4/tenancy-bundle | stancl/tenancy (Laravel) | RamyHakam (Symfony) | Manual |
|---|---|---|---|---|
| Database-per-tenant | ✅ | ✅ | ✅ | DIY |
| Shared-DB (SQL filter) | ✅ | ✅ | ❌ | DIY |
#[TenantAware] attribute |
✅ | ❌ (traits) | ❌ | ❌ |
| Cache isolation | ✅ | ✅ | ❌ | ❌ |
| Mailer per-tenant | ✅ | ✅ | ❌ | ❌ |
| Messenger context propagation | ✅ | ✅ | ❌ | ❌ |
| 5 resolvers incl. Origin header | ✅ | ✅ | Host only | DIY |
CLI tenant context (tenancy:run) |
✅ | ✅ | ❌ | ❌ |
| Strict mode (default ON) | ✅ | ❌ | ❌ | ❌ |
One-command setup (tenancy:install) |
✅ | N/A | ❌ | ❌ |
| PHPUnit testing trait | ✅ | ✅ | ❌ | ❌ |
| PHPStan level 9 | ✅ | ❌ | ❌ | ❌ |
| Symfony Profiler / WDT panel | ✅ | N/A | ❌ | ❌ |
| Runnable demo + CI smoke gate | ✅ | ✅ | ❌ | ❌ |
A data leak across tenants is a security incident, not a config mistake — so strict mode is on by default. Opt out explicitly if you understand the trade-off.
The bundle is a kernel extension, not just a database switcher: every Symfony subsystem (database, cache, queue, mailer, filesystem) participates in the tenant lifecycle through the same event-driven bootstrapper model. Doctrine is treated as an optional dependency — every entry point is guarded by class_exists / interface_exists, so the bundle installs cleanly into a Symfony app that doesn't use Doctrine at all.
- PHP
^8.2 - Symfony
^7.4or^8.0 - Optional:
doctrine/orm ^3,doctrine/dbal ^4,doctrine/migrations,symfony/messenger,symfony/mailer
The full docs site is published from docs/ to https://danplaton4.github.io/tenancy-bundle/.
Highlights:
- Getting started
- Database-per-tenant guide
- Shared-DB guide
- Cache isolation
- Messenger integration
- Origin-header resolver (SPA)
- Profiler tab
- Testing with
InteractsWithTenancy - Architecture (contributor guide)
See the roadmap on the documentation site for what's shipping next and what's tracked-but-unscheduled. Open a GitHub issue if you want something prioritized — real demand is the single strongest input to the next milestone's scope.
See CONTRIBUTING.md. Bug reports, design discussions, and PRs are all welcome — the bundle is small enough that the first contributor read of the code can land a real change in a single session.
MIT License. See LICENSE.