danplaton4 / tenancy-bundle
Multi-tenant Symfony bundle - zero boilerplate, zero leaks.
Package info
github.com/danplaton4/tenancy-bundle
Type:symfony-bundle
pkg:composer/danplaton4/tenancy-bundle
Requires
- php: ^8.2
- nikic/php-parser: ^5.0
- symfony/cache: ^7.4||^8.0
- symfony/config: ^7.4||^8.0
- symfony/console: ^7.4||^8.0
- symfony/dependency-injection: ^7.4||^8.0
- symfony/event-dispatcher: ^7.4||^8.0
- symfony/http-foundation: ^7.4||^8.0
- symfony/http-kernel: ^7.4||^8.0
- symfony/process: ^7.4||^8.0
Requires (Dev)
- doctrine/dbal: ^4.4
- doctrine/doctrine-bundle: ^2.13||^3.0
- doctrine/migrations: ^3.9
- doctrine/orm: ^3.3
- friendsofphp/php-cs-fixer: ^3.0
- league/flysystem: ^3.34
- league/flysystem-bundle: ^3.7
- league/flysystem-memory: ^3.31
- liip/monitor-bundle: ^2.25
- phpstan/extension-installer: ^1.4
- phpstan/phpstan: ^2.1
- phpstan/phpstan-doctrine: ^2.0
- phpunit/phpunit: ^11.0
- symfony/framework-bundle: ^7.4||^8.0
- symfony/mailer: ^7.4||^8.0
- symfony/messenger: ^7.4||^8.0
- symfony/mime: ^7.4||^8.0
- symfony/phpunit-bridge: ^7.4||^8.0
- symfony/twig-bundle: ^7.4||^8.0
- symfony/web-profiler-bundle: ^7.4||^8.0
Suggests
- doctrine/dbal: Required for database drivers (^4.4)
- doctrine/doctrine-bundle: Required for Doctrine integration (^2.13||^3.0)
- doctrine/migrations: Required for tenancy:migrate command (^3.9)
- doctrine/orm: Required for Tenant entity (^3.3)
- league/flysystem-bundle: Required for per-tenant Filesystem bootstrapper (Phase 24 / BOOT-03) (^3.7)
- league/flysystem-memory: In-memory Flysystem adapter — used by the bundle integration tests AND the memory:// DSN scheme of AdapterDsnParser (^3.31)
- liip/monitor-bundle: Optional liip/monitor-bundle integration: when installed, HealthCheckIntegrationPass auto-registers a TenantConnectivityCheck as a liip_monitor.check service (HEALTH-07). Endpoints and CLI command work without it. (^2.25)
- phpstan/extension-installer: For zero-config auto-loading of the tenancy PHPStan rules
- phpstan/phpstan-doctrine: For full Doctrine metadata support — enables XML/YAML-mapped entity analysis in Rule 3
- symfony/mailer: Required for per-tenant mailer bootstrapper (Phase 20) (^7.4||^8.0)
- symfony/messenger: Required for tenant context preservation across async message processing (^7.4||^8.0)
- symfony/web-profiler-bundle: Adds a 'Tenancy' panel to the Symfony Profiler / WDT in dev — shows tenant slug, label, driver, resolver, bootstrappers, error state. Auto-registered only when kernel.debug=true; never loaded in prod. Most Symfony skeleton apps already have this in require-dev.
README
Tenancy Bundle
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
- The Flysystem filesystem scopes each tenant's uploads
- 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.
Why this exists
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.
Quality signals
- PHPStan level 9 clean — no
@phpstan-ignore, nomixedshortcuts - 970 tests / 3,830 assertions across the unit + integration suites
- CI matrix: PHP 8.2 / 8.3 / 8.4 × Symfony 7.4 / 8.0 / 8.1, plus
prefer-lowest, "No Doctrine", "No Messenger", and acomposer auditsupply-chain gate 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
Install
composer require danplaton4/tenancy-bundle
Register 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: true
Mark 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.
Try the demo
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 proof
See 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.
Features
Isolation
- Database-per-tenant — DBAL connection switches at runtime per tenant via
TenantDriverMiddleware, nowrapper_classconfig required - Shared-database — Doctrine SQL filter with the
#[TenantAware]attribute; zero manual query scoping; strict-mode by default
Per-tenant subsystems (bootstrappers)
- Cache namespace isolation — per-tenant cache pool prefixing, no cross-tenant bleed
- Mailer — per-tenant SMTP DSN +
From+Reply-Toheaders, sync + async safe via theX-Transportstrategy - Filesystem (Flysystem) — per-tenant storage in
prefixmode (default) or a per-tenant adapter for S3-style separation - Messenger context propagation —
TenantStampon every envelope, re-booted on consume; works under sync and async transports
Data sharing
- Shared entities (
#[Shared]) — landlord-side master records replicate to a tenant-side read-only copy via Doctrine events, with opt-in async fan-out over Messenger and a compile-time#[Shared]⊕#[TenantAware]mutual-exclusion guard.tenancy:shared:resyncfor bulk/initial sync.
Operations & scale
- Maintenance mode — per-tenant HTTP 503 +
Retry-After, IP/route/path allow-list bypass, optional Twig template;tenancy:maintenance:enable|disable|status. Other tenants and the landlord keep serving. - Health checks —
GET /_tenancy/health/live+/_tenancy/health/ready/{slug}in IETFapplication/health+json, a bounded fleet dashboard, and atenancy:healthCLI. Optionalliip/monitor-bundleauto-registration. Every response is DSN-redacted. - Parallel migrations —
tenancy:migrate --parallelruns per-tenant migrations concurrently through a bounded subprocess pool (--concurrency,--dry-run,--format=json); the no-flag path stays sequential.
Resolution & DX
- 5 built-in resolvers — Host (subdomain), Origin header (SPA-friendly, allow-listed),
X-Tenant-IDheader, query param, CLI--tenantflag. Chain in any order via config; add your own. - CLI commands —
tenancy:install(one-shot setup),tenancy:init(scaffold config),tenancy:migrate(per-tenant,--parallel),tenancy:run(wrap any command in tenant context), plus the maintenance, health, andshared:resynccommands above - 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 - PHPStan extension — three consumer-facing static rules (
tenancy.mutualExclusion,tenancy.sharedEntityLeak,tenancy.tenantIdDrift) auto-loaded viaphpstan/extension-installer - 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
How it works
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
└─ FilesystemBootstrapper
│
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.
Comparison
| 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 | ✅ | ✅ | ❌ | ❌ |
| Filesystem per-tenant (Flysystem) | ✅ | ✅ | ❌ | ❌ |
Shared-entity replication (#[Shared]) |
✅ | ✅ | ❌ | DIY |
| Messenger context propagation | ✅ | ✅ | ❌ | ❌ |
| 5 resolvers incl. Origin header | ✅ | ✅ | Host only | DIY |
CLI tenant context (tenancy:run) |
✅ | ✅ | ❌ | ❌ |
| Parallel migrations | ✅ | ⚠️ | ❌ | DIY |
| Per-tenant maintenance mode | ✅ | ❌ | ❌ | DIY |
| Health-check endpoints | ✅ | ❌ | ❌ | DIY |
| Strict mode (default ON) | ✅ | ❌ | ❌ | ❌ |
One-command setup (tenancy:install) |
✅ | N/A | ❌ | ❌ |
| PHPUnit testing trait | ✅ | ✅ | ❌ | ❌ |
| PHPStan level 9 + extension | ✅ | ❌ | ❌ | ❌ |
| Symfony Profiler / WDT panel | ✅ | N/A | ❌ | ❌ |
| Runnable demo + CI smoke gate | ✅ | ✅ | ❌ | ❌ |
Philosophy
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.
Requirements
- PHP
^8.2 - Symfony
^7.4,^8.0, or^8.1 - Optional:
doctrine/orm ^3,doctrine/dbal ^4,doctrine/migrations,symfony/messenger,symfony/mailer,league/flysystem-bundle,liip/monitor-bundle
Documentation
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 - Operations: maintenance mode · health checks · parallel migrations
- Architecture (contributor guide)
Roadmap
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.
Contributing
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.
License
MIT License. See LICENSE.