customable/tenancy-bundle (2.8.0)
Installation
{
"repositories": [{
"type": "composer",
"url": " "
}
]
}composer require customable/tenancy-bundle:2.8.0About this package
Customable Tenancy Bundle
A production-ready multi-tenant bundle for Symfony 7.3+ / 8.0+ applications.
Inspired by Tenancy for Laravel and Sprout Laravel.
Requirements
- PHP 8.4+
- Symfony 7.3+ or 8.0+
- Doctrine ORM 3.0+
- PostgreSQL 12+, MySQL 8.0+, or MariaDB 10.5+
Installation
composer require customable/tenancy-bundle
Quick Start
1. Create your Tenant entity:
use Customable\TenancyBundle\Contract\TenantInterface;
use Customable\TenancyBundle\Trait\HasTenantData;
#[ORM\Entity]
class Tenant implements TenantInterface
{
use HasTenantData;
#[ORM\Id, ORM\GeneratedValue, ORM\Column]
private ?int $id = null;
#[ORM\Column(unique: true)]
private string $key = '';
public function getTenantIdentifier(): string { return $this->key; }
public function getTenantKey(): string|int { return $this->id ?? 0; }
public function getTenantSchema(): string { return 'tenant_' . $this->key; }
public function getTenantData(): array { return []; }
}
2. Create your repository:
use Customable\TenancyBundle\Repository\AbstractTenantRepository;
class TenantRepository extends AbstractTenantRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Tenant::class);
}
}
3. Configure:
# config/packages/tenancy.yaml
tenancy:
tenant_class: App\Entity\Tenant
strategy:
type: schema # schema, database, or column
identification:
central_domains: [example.com]
routing:
domain_pattern: "{tenant}.example.com"
4. Use in controllers:
use Customable\TenancyBundle\Attribute\CurrentTenant;
#[Route('/dashboard')]
public function dashboard(#[CurrentTenant] TenantInterface $tenant): Response
{
return new Response("Welcome to {$tenant->getTenantIdentifier()}!");
}
Features
Isolation Strategies
| Strategy | How it works | Best for |
|---|---|---|
| Schema | PostgreSQL schemas / MySQL databases per tenant | Strong isolation, recommended |
| Database | Separate database connections per tenant | Maximum isolation |
| Column | Shared tables with automatic tenant_id filtering |
Simple setups, fewer tenants |
Tenant Identification
8 built-in resolvers, configurable priority chain:
- Subdomain —
acme.example.com - Domain —
acme-corp.de - Header —
X-Tenant: acme - Path —
/tenant/acme/... - Query —
?tenant=acme - Cookie / Session / Request body
Bootstrappers
12 bootstrappers for automatic resource isolation:
| Bootstrapper | Isolates |
|---|---|
| Database | Schema/database switching |
| Cache | Key prefixing |
| Filesystem | Path isolation |
| Session | Session isolation |
| Cookie | Cookie domain/path |
| Sender address | |
| Broadcasting | Channel prefix |
| RootUrl | Application URL |
| TenantConfig | Runtime parameters |
| DatabaseCache | DB-backed cache |
| PersistentQueue | Messenger workers |
| PostgresRLS | Row-Level Security |
Console Commands
# Tenant management
tenancy:tenant:create acme "Acme Corp"
tenancy:tenant:list
tenancy:tenant:delete acme
# Run any command in tenant context
tenancy:run acme cache:clear
tenancy:run all doctrine:schema:validate
# Migrations (auto-tagged with #[AsCentralMigration] / #[AsTenantMigration])
tenancy:make:migration # tenant migration (default)
tenancy:make:migration central # central migration
tenancy:make:migration --empty # empty migration template
tenancy:migrate all
tenancy:migration:status
tenancy:migration:rollback acme --version=prev
# Maintenance mode
tenancy:maintenance enable acme --message="Upgrading" --until="+2 hours"
tenancy:maintenance disable acme
tenancy:maintenance status acme
# Performance
tenancy:performance:analyze acme --show-indexes --show-pool
# Backup
tenancy:backup acme
tenancy:backup # all tenants
# Cache
tenancy:cache:warmup acme
tenancy:cache:warmup # all tenants
# Health check
tenancy:health:check
Tenant Lifecycle
// Suspend a tenant (blocks access with 503)
$lifecycleManager->suspend($tenant, 'Payment overdue');
// Check state
$lifecycleManager->isSuspended($tenant); // true
$lifecycleManager->getState($tenant); // TenantState::Suspended
// Reactivate
$lifecycleManager->unsuspend($tenant);
Maintenance Mode
// Enable with optional expiry
$maintenanceManager->enable($tenant, 'Upgrading database', new DateTimeImmutable('+2 hours'));
// Auto-disables when expiry passes
$maintenanceManager->isInMaintenance($tenant);
Rate Limiting & Quotas
// Rate limiting
$result = $rateLimiter->consume('api');
if (!$result->allowed) {
// $result->retryAfterSeconds
}
// Quota management
$quotaManager->recordUsage($tenant, 'api_calls', 1);
$usage = $quotaManager->getUsage($tenant, 'api_calls');
// $usage->currentUsage, $usage->limit, $usage->percentage
API Key Management
// Create key (default: cache-backed, override with DB implementation)
['key' => $rawKey, 'info' => $info] = $apiKeyManager->createKey($tenant, 'My API Key', ['read', 'write']);
// Validate
$info = $apiKeyManager->validateKey($rawKey);
Audit Logging
$auditLogger->log('user.login', 'User', $userId, ['ip' => $request->getClientIp()]);
$auditLogger->logEntityChange('Product', $productId, $changeSet);
Backup & Recovery
Implement BackupStrategyInterface or use the built-in PostgreSQLBackupStrategy:
$backupManager->backupTenant($tenant);
$backupManager->restoreTenant($tenant, '/path/to/backup.sql.gz');
$backupManager->listBackups($tenant);
Performance Tools
- ConnectionPoolManager — connection reuse across tenant switches
- QueryResultCache — tenant-aware query caching
- DatabaseStatisticsCollector — schema size, row counts, cache hit ratio
- IndexRecommendationService — slow query analysis
- TenantArchivingService — archive/restore inactive tenants
Monolog Integration
Automatic tenant context in all logs:
monolog:
handlers:
main:
processors:
- Customable\TenancyBundle\Logging\TenantContextProcessor
Feature Gates
Restrict routes and controllers to tenants with specific features enabled:
use Customable\TenancyBundle\Attribute\RequiresFeature;
#[Route('/api/v2')]
#[RequiresFeature('apiAccess')]
public function apiEndpoint(): Response { ... }
#[Route('/sso/login')]
#[RequiresFeature('ssoEnabled')]
public function ssoLogin(): Response { ... }
// Class-level: applies to all methods
#[RequiresFeature('apiAccess')]
class ApiController { ... }
Throws FeatureNotAvailableException (403) if the tenant's feature is disabled. Works with all boolean properties on TenantFeatures (apiAccess, ssoEnabled, customDomain).
Configuration Validation
Automatic validation of tenant configuration on creation, with built-in validators and a console command:
// Built-in validators:
// - FeatureLimitsValidator: maxUsers > 0, storageQuotaMb >= 0
// - MailConfigValidator: valid email addresses, DSN format
// - SchemaNameValidator: safe PostgreSQL schema names
// Custom validator:
use Customable\TenancyBundle\Validation\TenantConfigValidatorInterface;
use Customable\TenancyBundle\Validation\ValidationError;
class MyCustomValidator implements TenantConfigValidatorInterface
{
public function validate(TenantInterface $tenant): array
{
$errors = [];
// Your validation logic...
return $errors;
}
}
# Validate a single tenant
tenancy:config:validate acme
# Validate all tenants
tenancy:config:validate --all
Validation runs automatically on TenantCreatingEvent and throws InvalidTenantConfigException on failure.
Event System
39 lifecycle events for extensibility:
// Listen to tenant initialization
#[AsEventListener]
public function onTenancyInitialized(TenancyInitializedEvent $event): void
{
$tenant = $event->tenant;
}
Key events: TenancyInitializing/Initialized, TenancyEnding/Ended, TenantCreating/Created, DatabaseMigrating/Migrated, BackupStarted/Completed, TenantSuspended/Unsuspended.
Messenger Integration
Tenant context is automatically carried through async messages:
framework:
messenger:
buses:
messenger.bus.default:
middleware:
- Customable\TenancyBundle\Messenger\Middleware\AddTenantStampMiddleware
- Customable\TenancyBundle\Messenger\Middleware\ResolveTenantMiddleware
Configuration Reference
tenancy:
tenant_class: App\Entity\Tenant # Required
tenant_repository: ~ # Auto-detected if null
strategy:
type: schema # schema, database, column
prefix: tenant_
connections:
central: default
tenant: default
identification:
hook: kernel.request # kernel.request, routing, middleware
resolvers: [] # Auto-discovered via tags
central_domains: []
header_name: X-Tenant
path_prefix: tenant
query_parameter: tenant
excluded_paths: [/health, /_wdt, /_profiler]
bootstrappers:
database: true
cache: true
filesystem: true
session: false
cookie: false
filesystem_base_path: tenants
cache_prefix: tenant_
migrations:
central_path: '%kernel.project_dir%/migrations/central'
tenant_path: '%kernel.project_dir%/migrations/tenant'
messenger:
enabled: true
routing:
enabled: true
scheme: https
domain_pattern: '{tenant}.localhost'
fallback_domain: localhost
twig_extension: true
performance:
connection_pooling:
enabled: false
min_size: 5
max_size: 50
query_cache:
enabled: false
default_t
Dependencies
Dependencies
| ID | Version |
|---|---|
| doctrine/dbal | ^4.0 |
| doctrine/doctrine-bundle | ^2.12 || ^3.0 |
| doctrine/orm | ^3.0 |
| php | >=8.4 |
| symfony/config | ^7.3 || ^8.0 |
| symfony/console | ^7.3 || ^8.0 |
| symfony/dependency-injection | ^7.3 || ^8.0 |
| symfony/event-dispatcher | ^7.3 || ^8.0 |
| symfony/framework-bundle | ^7.3 || ^8.0 |
| symfony/http-kernel | ^7.3 || ^8.0 |
| symfony/messenger | ^7.3 || ^8.0 |
Development dependencies
| ID | Version |
|---|---|
| friendsofphp/php-cs-fixer | ^3.92 |
| monolog/monolog | ^3.10 |
| phpstan/phpstan | ^2.0 |
| phpunit/phpunit | ^11.0 || ^12.0 |
| symfony/cache | ^7.3 || ^8.0 |
| symfony/security-bundle | ^7.3 || ^8.0 |
| symfony/security-core | ^7.3 || ^8.0 |
| symfony/var-dumper | ^7.3 || ^8.0 |
| twig/twig | ^3.0 |