A fail-closed Symfony 7.4 LTS and Symfony 8.x bundle for building multi-tenant applications, with PostgreSQL RLS as an optional defense in depth.
RC9 supports Symfony Scheduler's persistent RedispatchMessage path without
weakening tenant/global classification. Every main HTTP request, received
Messenger message, reused Console command, and
TenantExecutionBoundaryInterface callback starts from NONE.
TenantContext remains a shared mutable service; it is not recreated for every
request.
- π’ Multiple Tenant Resolution Strategies: Subdomain, path-based, header-based, domain-based, DNS TXT, hybrid, or custom resolvers
- ποΈ Database Strategies: Shared database with filtering or separate databases per tenant
- β‘ Performance Optimized: Built-in caching for tenant settings and configurations
- π§ Doctrine Integration: Automatic tenant filtering with Doctrine ORM
- π§ Tenant-Aware Services: Mailer with automatic tenant propagation, Messenger with context preservation, and file storage integration
- π― Event-Driven: Database switching events and automatic tenant context resolution
- π οΈ Advanced Commands: Schema management, migrations, and fixtures for tenants
- π§ͺ Comprehensive Test Kit: First-class testing utilities to prove tenant isolation works
- π RLS Integration: PostgreSQL Row-Level Security for defense-in-depth
- π PHPStan Level Max: Static analysis at maximum level
Tenant-aware Doctrine reads and writes require a valid current tenant and reject invalid mappings, stale filter state, tenant changes, and cross-tenant mutations. Global ORM operations are explicit callbacks through GlobalDoctrineScopeInterface; direct filter disabling is outside the supported contract.
Messenger messages implement exactly one of TenantAwareMessageInterface or GlobalMessageInterface. Tenant-aware messages require consistent tenant metadata at send and receive time, while global messages must never carry a tenant stamp. See the RC1 to RC2 migration guide.
Messenger transport selection is explicit: tenant_transport preserves the historical per-tenant map/default behavior, while symfony_routing leaves transport stamps untouched so framework.messenger.routing and #[AsMessage] work natively. Native mode has no bundle fallback; an unrouted message with a handler may run synchronously. See Messenger and the RC7 to RC8 migration guide.
Persistent Symfony Scheduler work uses a classified application message inside
Symfony's RedispatchMessage, with an explicit persistent destination. Directly
scheduling the application message can execute its handler in the Scheduler
Worker because the Scheduler envelope is already marked as received. See the
Scheduler recipe and the RC8 to RC9 migration guide.
Install the bundle via Composer:
composer require "zhortein/multi-tenant-bundle:1.0.0-rc.9"The core dependency set and optional Mailer, Twig, Monolog, and PSR-16 integrations are listed in the dependency classification.
Enable the bundle in your config/bundles.php:
<?php
return [
// ...
Zhortein\MultiTenantBundle\ZhorteinMultiTenantBundle::class => ['all' => true],
];<?php
namespace App\Entity;
use Doctrine\ORM\Mapping as ORM;
use Zhortein\MultiTenantBundle\Entity\TenantInterface;
#[ORM\Entity]
#[ORM\Table(name: 'tenants')]
class Tenant implements TenantInterface
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')]
private ?int $id = null;
#[ORM\Column(type: 'string', length: 255, unique: true)]
private string $slug;
#[ORM\Column(type: 'string', length: 255)]
private string $name;
// Implement TenantInterface methods...
public function getId(): ?int
{
return $this->id;
}
public function getSlug(): string
{
return $this->slug;
}
public function setSlug(string $slug): void
{
$this->slug = $slug;
}
// ... other methods
}Create config/packages/zhortein_multi_tenant.yaml:
zhortein_multi_tenant:
tenant_entity: 'App\Entity\Tenant'
database:
strategy: 'shared_db'
enable_filter: true
rls:
enabled: false
fixtures:
enabled: false
mailer:
enabled: false<?php
namespace App\Entity;
use Doctrine\ORM\Mapping as ORM;
use Zhortein\MultiTenantBundle\Attribute\AsTenantAware;
use Zhortein\MultiTenantBundle\Entity\TenantAwareEntityTrait;
#[ORM\Entity]
#[AsTenantAware]
class Product
{
use TenantAwareEntityTrait;
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')]
private ?int $id = null;
#[ORM\Column(type: 'string', length: 255)]
private string $name;
// ... other properties and methods
}<?php
namespace App\Controller;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Zhortein\MultiTenantBundle\Context\TenantContextInterface;
class DashboardController extends AbstractController
{
public function index(TenantContextInterface $tenantContext): Response
{
$tenant = $tenantContext->getTenant();
// All database queries are automatically filtered by tenant
$products = $this->entityManager
->getRepository(Product::class)
->findAll(); // Only returns current tenant's products
return $this->render('dashboard/index.html.twig', [
'tenant' => $tenant,
'products' => $products,
]);
}
}- Installation & Setup - Complete installation guide
- Compatibility Policy - Tested PHP, Symfony, and Doctrine combinations
- Configuration Reference - All configuration options
- Database Strategies - Shared DB vs Multi-DB
- Tenant Context - Tenant resolution and access
- Tenant Resolution - Resolution strategies
- DNS TXT Resolver - DNS-based tenant resolution
- Domain Resolvers - Domain and hybrid resolvers
- Doctrine Tenant Filter - Automatic filtering
- Tenant Settings - Configuration system
- Mailer - Tenant-aware email with templated support
- Messenger - Tenant-aware queues with automatic context propagation
- Scheduler - Classified redispatch through a persistent transport
- Storage - Fail-closed file storage isolation
- Security Contract Migration - Required storage, cache, mailer, and observability migration
- RC4 to RC5 Migration - Persistent lifecycle reset and early/late HTTP resolution
- RC7 to RC8 Migration - Choose tenant-specific or native Symfony Messenger routing
- RC8 to RC9 Migration - Adopt fail-closed persistent Scheduler redispatch
- Persistent Process Lifecycle - Complete state inventory, reset order, and failure behavior
- Migrations - Database migrations
- Fixtures - Test data loading
- CLI Commands - Console commands
- Testing - Testing strategies and Test Kit
- FAQ - Common questions
- Basic Usage - Code examples
- Mailer Examples - Email templates and configuration
- Messenger Examples - Message routing and handling
- Service Integration - Practical implementations
The optional public Test Kit provides three intentionally small APIs:
TenantContextScopeexecutes a callback under a consumer-defined tenant and restores the previous context in all outcomes;TenantKernelTestCaseintegrates that scope with Symfony kernel tests;TenantWebTestCaseintegrates the same lifecycle with Symfony functional tests without selecting a resolver or database strategy.
<?php
use App\Entity\Tenant;
use Zhortein\MultiTenantBundle\Test\TenantKernelTestCase;
final class ProductRepositoryTest extends TenantKernelTestCase
{
public function testTenantIsolation(): void
{
$tenantA = new Tenant("tenant-a");
$tenantB = new Tenant("tenant-b");
self::assertSame(
["A product"],
$this->withTenant($tenantA, fn (): array => $this->repository->findForTenant($tenantA)),
);
self::assertSame(
["B product"],
$this->withTenant($tenantB, fn (): array => $this->repository->findForTenant($tenantB)),
);
}
}# Run the complete bundle suite
make test
# Run unit and integration subsets
make test-unit
make test-integration
# Run effective PostgreSQL RLS isolation tests
make test-with-postgresSee the Testing Documentation for installation, lifecycle, and consumer examples.
# PHPStan at maximum level
make phpstan
# PHP-CS-Fixer code style check
make csfixer-check
# Fix code style
make csfixer
# Run all quality checks
make dev-check- Fork the repository
- Create a feature branch
- Write tests for your changes
- Ensure all tests pass and code meets quality standards
- Submit a pull request
See CONTRIBUTING.md for detailed guidelines.
This bundle is released under the MIT License. See the LICENSE file for details.
- Documentation: Complete documentation
- Issues: GitHub Issues
- Discussions: GitHub Discussions
The optional object storage core provides durable tenant-aware references and a backend-independent contract, disabled by default. The optional Flysystem/S3-compatible bridge adds real MinIO proofs and leaves the historical file API unchanged. RC12 remains a prerelease. Its optional audit API adds lazy location inventory, explicit scopes and logical identity observations. Audit is disabled by default; historical objects without identity remain readable. Upgrading enables no integration and runs no SQL, object or metadata migration. See the RC11 to RC12 migration guide, including the trust boundary, progressive adoption and application rollback.
See CHANGELOG.md for version history and upgrade instructions.