Search by

zhortein / multi-tenant-bundle

Zhortein

A fail-closed Symfony 7.4 LTS and Symfony 8.x multi-tenant bundle with PostgreSQL >= 16 defense-in-depth support.

Package info

github.com/Zhortein/multi-tenant-bundle

Homepage

Issues

Documentation

Type:symfony-bundle

pkg:composer/zhortein/multi-tenant-bundle

Statistics

Installs: 302

Dependents: 0

Suggesters: 0

Stars: 6

v1.0.0-rc.12 2026-09-13 09:01 UTC

README

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.

PHP Version Symfony Version PostgreSQL Version

Features

  • ๐Ÿข 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

Fail-closed security contract

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.

Installation

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],
];

Quick Start

1. Create Your Tenant Entity

<?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
}

2. Configure the Bundle

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

3. Create Tenant-Aware Entities

<?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
}

4. Use in Controllers

<?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,
        ]);
    }
}

๐Ÿ“š Documentation

๐Ÿš€ Getting Started

๐Ÿ—๏ธ Core Concepts

๐Ÿ”ง Service Integration

๐Ÿ—„๏ธ Database Management

๐Ÿ› ๏ธ Development Tools

๐Ÿ“– Examples

Testing with the Bundle

The optional public Test Kit provides three intentionally small APIs:

  • TenantContextScope executes a callback under a consumer-defined tenant and restores the previous context in all outcomes;
  • TenantKernelTestCase integrates that scope with Symfony kernel tests;
  • TenantWebTestCase integrates the same lifecycle with Symfony functional tests without selecting a resolver or database strategy.

Quick Example

<?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)),
        );
    }
}

Running Tests

# 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-postgres

See the Testing Documentation for installation, lifecycle, and consumer examples.

Code Quality

# 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

Contributing

  1. Fork the repository
  2. Create a feature branch
  3. Write tests for your changes
  4. Ensure all tests pass and code meets quality standards
  5. Submit a pull request

See CONTRIBUTING.md for detailed guidelines.

License

This bundle is released under the MIT License. See the LICENSE file for details.

Support

Object storage

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.

Changelog

See CHANGELOG.md for version history and upgrade instructions.