Skip to content

danplaton4/tenancy-bundle

Repository files navigation

CI demo-smoke Latest Stable Version PHP Version License codecov

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
  • 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, no mixed shortcuts
  • 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-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)
  • 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:install handles both steps in a single command. It uses nikic/php-parser to AST-edit config/bundles.php safely — 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

  • Database-per-tenant — DBAL connection switches at runtime per tenant via TenantDriverMiddleware, no wrapper_class config 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-ID header, query param, CLI --tenant flag. 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-To headers, sync + async safe via X-Transport strategy
  • Messenger context propagationTenantStamp attached 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 commandstenancy:install (one-shot setup), tenancy:init (scaffold config), tenancy:migrate (run migrations per tenant), tenancy:run (wrap any command with tenant context)
  • PHPUnit testing traitInteractsWithTenancy sets up a clean tenant DB/schema per test method, real SQLite, no mocks
  • Custom entity support — extend AbstractTenant (MappedSuperclass) to add columns like brandColor, plan, billingId without 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
                         │
                   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
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

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 or ^8.0
  • Optional: doctrine/orm ^3, doctrine/dbal ^4, doctrine/migrations, symfony/messenger, symfony/mailer

Documentation

The full docs site is published from docs/ to https://danplaton4.github.io/tenancy-bundle/.

Highlights:

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.