apajo/symfony-multi-tenancy-bundle
Install the Bundle
composer require apajo/symfony-multi-tenancy-bundle
Enable in config/bundles.php:
aPajo\MultiTenancyBundle\APajoMultiTenancyBundle::class => ['all' => true],
Configure Doctrine
Define two connections (default and tenant) and entity managers in config/packages/doctrine.yaml:
doctrine:
dbal:
connections:
default: { url: '%env(DEFAULT_DATABASE_URL)%' }
tenant: { url: '%env(TENANT_DATABASE_URL)%' }
orm:
entity_managers:
default: { connection: default }
tenant: { connection: tenant, mappings: [...] }
Configure Tenant & Adapters
Define tenant entity, adapters, and resolvers in config/packages/apajo_multi_tenancy.yaml:
apajo_multi_tenancy:
tenant:
class: App\Entity\Tenant
identifier: key
entity_manager: default
resolvers:
- aPajo\MultiTenancyBundle\Service\Resolver\HostBasedResolver
adapters:
- aPajo\MultiTenancyBundle\Adapter\Database\DatabaseAdapter
- aPajo\MultiTenancyBundle\Adapter\Filesystem\FilesystemAdapter
Register Adapters as Services
Example for FilesystemAdapter in config/services.yaml:
services:
aPajo\MultiTenancyBundle\Adapter\Filesystem\FilesystemAdapter:
arguments: ['@sonata.media.adapter.filesystem.local']
First Tenant Switch
Use the EnvironmentProvider to switch tenants dynamically:
use aPajo\MultiTenancyBundle\Service\EnvironmentProvider;
$tenant = $tenantRepository->find('tenant_key');
$environmentProvider->select($tenant);
Resolver Integration
Use built-in resolvers (e.g., HostBasedResolver) to auto-detect tenants via:
tenant.example.com)X-Tenant-ID)TenantResolverInterface).Adapter-Based Configuration Dynamically override system configurations per tenant:
// FilesystemAdapter: Switch storage paths
$adapter = $container->get(FilesystemAdapter::class);
$adapter->setTenantConfig($tenant);
// MailerAdapter: Override transport per tenant
$mailerAdapter = $container->get(MailerAdapter::class);
$mailerAdapter->setTenantConfig($tenant);
Entity Manager Context
Use the tenant entity manager for tenant-specific data:
$tenantRepo = $entityManager->getRepository('App\Entity\Tenant');
$tenantRepo->find('key'); // Uses tenant EM
Migrations
php bin/console tenants:migrations:diff
php bin/console tenants:migrations:migrate [tenant_id]
Tenant-Specific Services
Create services that inject EnvironmentProvider to access tenant configs:
class TenantAwareService {
public function __construct(private EnvironmentProvider $envProvider) {}
public function getTenantConfig() {
return $this->envProvider->getCurrentTenant()->getConfig();
}
}
Middleware for Tenant Resolution Use Symfony middleware to resolve tenants early in the request lifecycle:
use aPajo\MultiTenancyBundle\Service\Resolver\TenantResolverInterface;
class TenantMiddleware implements MiddlewareInterface {
public function __construct(private TenantResolverInterface $resolver) {}
public function process(Request $request, RequestHandler $handler): Response {
$tenant = $this->resolver->resolve($request);
$envProvider->select($tenant);
return $handler->handle($request);
}
}
Event-Driven Tenant Switching
Dispatch TenantSelectEvent to trigger tenant-specific logic:
$event = new TenantSelectEvent($tenant);
$dispatcher->dispatch($event);
Default Connection Dependency
default connection.default connection is always present, even if unused.Entity Manager Context Leaks
default and tenant entity managers in the same transaction.tenant EM exclusively for tenant-specific operations.Resolver Conflicts
apajo_multi_tenancy.yml or use a custom resolver.Migration Gaps
tenant connection is misconfigured.tenants:migrations:diff before applying.Adapter Initialization Order
TenantSelectEvent listeners to configure adapters.Check Current Tenant Log the active tenant in middleware or controllers:
$tenant = $envProvider->getCurrentTenant();
error_log("Active Tenant: " . $tenant->getKey());
Profiler Limitations Symfony Profiler may not show tenant-specific data. Use custom data collectors:
class TenantDataCollector implements DataCollectorInterface {
public function collect(Request $request, Response $response, \Exception $exception = null) {
return ['tenant' => $envProvider->getCurrentTenant()];
}
}
Adapter Debugging Enable adapter logging to trace config changes:
services:
aPajo\MultiTenancyBundle\Adapter\Database\DatabaseAdapter:
arguments: ['@doctrine']
calls:
- [setDebug, [true]]
Custom Adapters
Extend AdapterInterface to support new services (e.g., caching, queues):
class CacheAdapter implements AdapterInterface {
public function apply(TenantInterface $tenant) {
$cache = new RedisCache($tenant->getCacheConfig());
// Inject into container
}
}
Custom Resolvers
Implement TenantResolverInterface for unique tenant resolution logic:
class ApiKeyResolver implements TenantResolverInterface {
public function resolve(Request $request) {
$key = $request->headers->get('X-API-KEY');
return $tenantRepo->findOneBy(['api_key' => $key]);
}
}
Tenant-Specific Doctrine Events Listen for tenant-specific ORM events:
$tenantEM->getEventManager()->addEventListener(
LifecycleEventArgs::class,
function ($event) use ($tenant) {
$entity = $event->getObject();
if ($entity instanceof TenantAwareEntity) {
$entity->setTenant($tenant);
}
}
);
Fallback Tenant Handle cases where no tenant is resolved (e.g., admin panel):
$envProvider->select($fallbackTenant);
Environment Variables
Use %env(TENANT_DATABASE_URL)% with placeholders for dynamic tenant DB URLs:
tenant:
url: '%env(TENANT_DATABASE_URL_%tenant_key)%'
Adapter Prioritization
Adapters are applied in the order defined in apajo_multi_tenancy.yml. Reorder as needed.
Migration Paths
Ensure migrations/tenant/ and migrations/default/ directories exist and are writable.
How can I help you explore Laravel packages today?