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.
Installation
composer require danplaton4/tenancy-bundle
Run the one-shot setup:
composer require --dev nikic/php-parser # Required for auto-config
bin/console tenancy:install
Configure Tenancy
Edit config/packages/tenancy.yaml:
tenancy:
driver: database_per_tenant # or shared_db
database:
enabled: true
First Use Case
Tenant with slug as primary key).#[TenantAware]:
use Tenancy\Bundle\Attribute\TenantAware;
#[ORM\Entity]
#[TenantAware]
class Invoice {}
Test Resolution
Access your app via a subdomain (e.g., tenant1.your-app.local) or set the X-Tenant-ID header to trigger resolution.
Resolver Chain
Configure resolvers in tenancy.yaml (e.g., host, header, query_param):
tenancy:
resolvers:
- host
- header
- query_param
query_param at 10 has highest priority).TenantResolverInterface and tag with tenancy.resolver.Database-Per-Tenant
DatabaseSwitchBootstrapper automatically switches the DBAL connection to the tenant’s database.bin/console tenancy:migrate
Shared-DB Mode
#[TenantAware] are automatically scoped to the active tenant.TenantMissingException if no tenant is resolved (default behavior).Bootstrappers
DoctrineBootstrapper: Manages Doctrine entity managers.CacheBootstrapper: Isolates cache pools by tenant.MailerBootstrapper: Swaps SMTP/DSN per tenant.MessengerBootstrapper: Propagates tenant context via TenantStamp.TenantBootstrapperInterface and tag with tenancy.bootstrapper:
use Tenancy\Bundle\Bootstrapper\TenantBootstrapperInterface;
class CustomBootstrapper implements TenantBootstrapperInterface {
public function boot(TenantContext $context): void {
// Reconfigure your subsystem here
}
}
CLI Tenant Context Run commands with a tenant context:
bin/console tenancy:run --tenant=acme doctrine:migrations:migrate
Testing
Use the InteractsWithTenancy trait in PHPUnit:
use Tenancy\Bundle\Test\InteractsWithTenancy;
class InvoiceTest extends TestCase {
use InteractsWithTenancy;
public function testInvoiceCreation() {
$this->setActiveTenant('acme');
// Test logic here
}
}
Resolver Conflicts
host and header), the lowest-priority resolver wins (e.g., host at 30 overrides header at 20).tenancy.yaml or use a custom resolver with higher priority.Shared-DB + Database-Per-Tenant Conflict
shared_db with database.enabled: true throws a validation error.database.enabled: false in shared_db mode.Cache Isolation Leaks
CacheBootstrapper or configure per-tenant cache prefixes manually.Messenger Transport Issues
doctrine) may not propagate tenant context if not configured.MessengerBootstrapper is enabled and TenantStamp is attached to envelopes.Doctrine Identity Map Pollution
EntityManagerResetListener (enabled by default) or reset the manager manually:
$this->getDoctrine()->getManager()->clear();
Strict Mode Pitfalls
TenantMissingException if no tenant is resolved.tenancy.yaml:
tenancy:
strict_mode: false
Profiler Tab
kernel.debug: true) to inspect the active tenant, resolver, and bootstrappers in the "Tenancy" panel.Logging Tenant Context
kernel.request listener:
use Tenancy\Bundle\Context\TenantContext;
public function onKernelRequest(RequestEvent $event, TenantContext $context) {
$this->logger->info('Active tenant:', ['tenant' => $context->getTenant()]);
}
Testing Resolvers
Request object to test resolver behavior:
$request = new Request([], [], ['HTTP_X_TENANT_ID' => 'acme']);
$this->setActiveTenantFromRequest($request);
Database Connection Issues
bin/console tenancy:run --tenant=acme doctrine:schema:validate
Custom Tenant Entity
Extend AbstractTenant to add custom fields:
use Tenancy\Bundle\Entity\AbstractTenant;
#[ORM\Entity]
class CustomTenant extends AbstractTenant {
#[ORM\Column]
private string $brandColor = '#000000';
}
Dynamic Bootstrappers Conditionally enable bootstrappers based on tenant attributes:
class DynamicMailerBootstrapper implements TenantBootstrapperInterface {
public function boot(TenantContext $context): void {
if ($context->getTenant()->isPremium()) {
$this->mailer->configurePremiumTransport();
}
}
}
Resolver Middleware Add middleware to inject tenant context early (e.g., for API gateways):
use Tenancy\Bundle\Resolver\TenantResolverInterface;
class TenantMiddleware implements MiddlewareInterface {
public function __construct(private TenantResolverInterface $resolver) {}
public function handle(Request $request, callable $next): Response {
$this->resolver->resolveFromRequest($request);
return $next($request);
}
}
Override Default Bootstrappers
Replace built-in bootstrappers by implementing TenantBootstrapperInterface and overriding the service tag:
services:
Tenancy\Bundle\Bootstrapper\DoctrineBootstrapper:
class: App\Bootstrapper\CustomDoctrineBootstrapper
tags: ['tenancy.bootstrapper']
How can I help you explore Laravel packages today?