danplaton4/tenancy-bundle
Multi-tenancy for Symfony with zero boilerplate: resolve a tenant once per request and the kernel reconfigures DBAL/Doctrine, cache pools, mailer transport, and Messenger. Automatic query scoping and tenant propagation to workers; your app code stays tenant-unaware.
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.
// 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-ignore, no mixed shortcutsprefer-lowest, "No Doctrine", "No Messenger", and a composer audit supply-chain gatedemo-smoke live-stack gate: every push to master rebuilds the three-tenant FrankenPHP + Caddy + MariaDB demo and exercises tenant isolation end-to-end via bin/smoke.sh (~90s)#[TenantAware] entity is an exception, not silent data leakagecomposer 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.
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.
Isolation
TenantDriverMiddleware, no wrapper_class config required#[TenantAware] attribute; zero manual query scoping; strict-mode by defaultPer-tenant subsystems (bootstrappers)
From + Reply-To headers, sync + async safe via the X-Transport strategyprefix mode (default) or a per-tenant adapter for S3-style separationTenantStamp on every envelope, re-booted on consume; works under sync and async transportsData sharing
#[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:resync for bulk/initial sync.Operations & scale
Retry-After, IP/route/path allow-list bypass, optional Twig template; tenancy:maintenance:enable|disable|status. Other tenants and the landlord keep serving.GET /_tenancy/health/live + /_tenancy/health/ready/{slug} in IETF application/health+json, a bounded fleet dashboard, and a tenancy:health CLI. Optional liip/monitor-bundle auto-registration. Every response is DSN-redacted.tenancy:migrate --parallel runs per-tenant migrations concurrently through a bounded subprocess pool (--concurrency, --dry-run, --format=json); the no-flag path stays sequential.Resolution & DX
X-Tenant-ID header, query param, CLI --tenant flag. Chain in any order via config; add your own.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, and shared:resync commands abovekernel.debug=true, compile-stripped in prodtenancy.mutualExclusion, tenancy.sharedEntityLeak, tenancy.tenantIdDrift) auto-loaded via phpstan/extension-installerInteractsWithTenancy sets up a clean tenant DB/schema per test method, real SQLite, no mocksAbstractTenant (MappedSuperclass) to add columns like brandColor, plan, billingId without breaking Doctrine inheritanceThe 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.
| 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 | ✅ | ✅ | ❌ | ❌ |
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.
^8.2^7.4, ^8.0, or ^8.1doctrine/orm ^3, doctrine/dbal ^4, doctrine/migrations, symfony/messenger, symfony/mailer, league/flysystem-bundle, liip/monitor-bundleThe full docs site is published from docs/ to https://danplaton4.github.io/tenancy-bundle/.
Highlights:
InteractsWithTenancySee 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.
How can I help you explore Laravel packages today?