SF
{ }
Symfony · Multi-Tenancy · SaaS · Doctrine
Symfony Multi-Tenancy:
ein Code, viele Mandanten

SaaS-Anwendungen müssen mehrere Kunden auf derselben Codebasis betreiben, ohne dass Daten zwischen Mandanten sichtbar werden. Symfony bietet keine eingebaute Multi-Tenancy-Lösung — aber mit einem klaren Tenant-Resolver, der richtigen Doctrine-Strategie und konsequenter Request-Scoping-Architektur entsteht ein System, das sicher, wartbar und skalierbar für beliebig viele Mandanten ist.

22 Min. Lesezeit Tenant-Resolver · Doctrine · Separate DB · Row-Level-Security Symfony 7.x · PHP 8.3+ · Doctrine ORM 3

1. Warum Multi-Tenancy in Symfony eine eigene Architektur braucht

Multi-Tenancy in Symfony bedeutet, dass eine einzige Codeinstanz für mehrere Kunden (Mandanten, Tenants) gleichzeitig läuft — mit vollständiger Datenisolation. Das klingt einfach, aber die Implikationen durchziehen die gesamte Architektur: Jeder Datenbankzugriff muss mandantenspezifisch sein, Sessions und Caches dürfen nicht überlappen, Queues müssen pro Mandant routbar sein, und Fehler in einem Mandanten-Kontext dürfen andere Mandanten nicht beeinflussen. Symfony bietet dafür keine eingebaute Abstraktion — das macht Multi-Tenancy zu einer Architekturentscheidung, nicht zu einem Feature, das man nachträglich einschaltet.

Die zwei häufigsten Fehler bei Multi-Tenancy-Implementierungen: Erstens wird die Tenant-Identifikation zu spät im Stack implementiert — im Controller statt im Event Listener des Kernels. Das führt dazu, dass Middleware, Caches und Services ohne Tenant-Kontext initialisiert werden. Zweitens werden Doctrine-Filter nur für einige Entities aktiviert, nicht global. Das ist eine Datenleck-Gefahr: Eine vergessene Relation kann Daten des falschen Mandanten zurückgeben. Richtig gemacht ist Multi-Tenancy in Symfony durchaus elegant: Der Tenant-Kontext wird einmal gesetzt, alle weiteren Schichten konsumieren ihn automatisch.

2. Die drei Datentrennung-Strategien im Vergleich

Für Multi-Tenancy-Systeme gibt es drei grundlegende Strategien der Datentrennung, die sich in Isolation, Skalierbarkeit und Betriebsaufwand erheblich unterscheiden. Die erste Strategie ist die separate Datenbank pro Mandant: Jeder Mandant bekommt eine eigene Datenbankinstanz. Das bietet maximale Isolation — ein Datenleck zwischen Mandanten ist auf Datenbankebene physisch unmöglich. Der Nachteil: Bei 500 Mandanten betreibt man 500 Datenbankinstanzen. Migrationen müssen für alle Datenbanken separat ausgeführt werden. Sinnvoll für Enterprise-SaaS mit wenigen, zahlenden Kunden die starke Compliance-Anforderungen haben.

Die zweite Strategie ist das separate Schema pro Mandant innerhalb einer Datenbankinstanz (unterstützt nativ von PostgreSQL). Jeder Mandant bekommt ein eigenes Schema mit identischer Tabellenstruktur. Ein Database-Connection-Switch genügt, um den Mandanten zu wechseln. Die dritte Strategie ist Row-Level-Security: Alle Mandanten teilen dieselben Tabellen, jede Zeile hat eine tenant_id-Spalte. Doctrine-Filter sorgen dafür, dass jede Query automatisch auf den aktuellen Mandanten eingeschränkt wird. Das ist die skalierbarste Option für SaaS mit vielen Kleinkunden — aber es erfordert äußerste Sorgfalt, dass kein Query-Pfad den Filter umgeht.


<?php

declare(strict_types=1);

namespace App\MultiTenancy\Domain;

/**
 * Value object representing an identified tenant in the system.
 */
final readonly class Tenant
{
    public function __construct(
        public readonly string $identifier,    // slug used in subdomains and URLs
        public readonly int $id,               // database surrogate key
        public readonly string $databaseName,  // used for separate-DB strategy
        public readonly string $schemaName,    // used for separate-schema strategy
        public readonly TenantStatus $status,
    ) {}

    /**
     * Check if this tenant is allowed to process requests.
     */
    public function isActive(): bool
    {
        return $this->status === TenantStatus::Active;
    }
}

enum TenantStatus: string
{
    case Active   = 'active';
    case Suspended = 'suspended';
    case Trial    = 'trial';
}

/**
 * Contract for resolving the current tenant from the request context.
 */
interface TenantResolverInterface
{
    /**
     * Resolve the tenant from the current request.
     *
     * @throws TenantNotFoundException if no tenant can be identified
     */
    public function resolve(\Symfony\Component\HttpFoundation\Request $request): Tenant;
}

3. Tenant-Resolver: den aktuellen Mandanten identifizieren

Der Tenant-Resolver ist die kritischste Komponente der Multi-Tenancy-Architektur in Symfony — er bestimmt, welchem Mandanten ein eingehender Request zugeordnet wird. Die häufigsten Identifikationsstrategien sind Subdomain-basiert (acme.myapp.de), Pfad-basiert (myapp.de/t/acme/dashboard) und Header-basiert (X-Tenant-ID: acme). Subdomain-basierte Resolver sind die cleanste Option für SaaS-Anwendungen, weil sie keine URL-Struktur vorgeben und DNS-Ebenen-Isolation ermöglichen. Header-basierte Resolver eignen sich für API-Gateways, die den Tenant nach Authentifizierung in einen Header schreiben.

Der Resolver schlägt das Tenant-Objekt aus dem zentralen Mandanten-Register nach — typischerweise einer Doctrine-Entity in der Shared-Datenbank oder einem Cache-basierten Store. Das Ergebnis wird im Request-Attribut gespeichert, sodass alle nachfolgenden Schichten es lesen können, ohne den Resolver erneut aufzurufen. Der Resolver sollte die Tenant-Daten cachen (mindestens per-Request, idealerweise mit kurzer TTL im Redis-Cache), um bei jedem HTTP-Request keine Datenbankabfrage für die Tenant-Auflösung auszuführen. Eine fehlschlagende Auflösung — weil der Subdomain-Name keinem Mandanten entspricht — wirft eine TenantNotFoundException, die in einen 404-Response übersetzt wird.

4. Tenant-Kontext im Request Lifecycle verankern

Der Tenant-Kontext muss so früh wie möglich im Request Lifecycle von Symfony gesetzt werden. Der richtige Einhängepunkt ist der KernelEvents::REQUEST-Event mit einer sehr hohen Priorität (z.B. 100), sodass der Kontext vor Routing, Security und Controller-Auflösung verfügbar ist. Ein Kernel-Event-Listener ruft den Tenant-Resolver auf und speichert das Tenant-Objekt in einem TenantContext-Service, der als Request-scoped Service im DI-Container registriert ist. Alle weiteren Services — Doctrine Connection, Cache-Prefix-Generator, Mailer-Absender — lesen den Kontext aus diesem Service.

Der TenantContext-Service ist ein einfaches stateful Value-Object im DI-Container. Er hält das aktuelle Tenant-Objekt und wirft eine Exception, wenn kein Tenant gesetzt ist und ein Service versucht, darauf zuzugreifen. Dieses Fail-Fast-Verhalten ist wichtig: Es verhindert, dass ein Request ohne Tenant-Kontext auf Daten zugreift und durch fehlende tenant_id-Filter Daten anderer Mandanten sieht. Bei Endpoints, die keinen Mandanten brauchen — Health-Checks, Webhook-Endpunkte — setzt der Listener keinen Kontext und alle Tenant-sensitiven Services sollten in diesem Fall eine konfigurierbare Exception oder einen Null-Tenant zurückgeben.


<?php

declare(strict_types=1);

namespace App\MultiTenancy\Infrastructure\EventListener;

use App\MultiTenancy\Application\TenantContext;
use App\MultiTenancy\Domain\TenantResolverInterface;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpKernel\KernelEvents;

/**
 * Resolves the current tenant from the request and stores it in TenantContext.
 * Must run before routing, security and controller resolution.
 */
#[AsEventListener(event: KernelEvents::REQUEST, priority: 100)]
final readonly class TenantResolverListener
{
    public function __construct(
        private TenantResolverInterface $resolver,
        private TenantContext $context,
    ) {}

    /**
     * Resolve tenant from request and set it as the active tenant context.
     */
    public function __invoke(RequestEvent $event): void
    {
        if (!$event->isMainRequest()) {
            return; // Skip sub-requests — tenant is already set from main request
        }

        $request = $event->getRequest();

        // Skip tenant resolution for paths that don't require a tenant context
        if ($this->isPublicPath($request->getPathInfo())) {
            return;
        }

        $tenant = $this->resolver->resolve($request);

        if (!$tenant->isActive()) {
            // Return a 403 for suspended tenants — no stack trace, no details
            $event->setResponse(new \Symfony\Component\HttpFoundation\Response(
                'Tenant account is suspended.',
                \Symfony\Component\HttpFoundation\Response::HTTP_FORBIDDEN,
            ));
            return;
        }

        $this->context->setTenant($tenant);

        // Store on request attributes for easy access in controllers
        $request->attributes->set('_tenant', $tenant);
    }

    private function isPublicPath(string $path): bool
    {
        return str_starts_with($path, '/_health') || str_starts_with($path, '/_metrics');
    }
}

5. Separate Datenbanken mit dynamischem Doctrine-Connection

Bei der Strategie mit separaten Datenbanken pro Mandant wechselt Doctrine dynamisch die Datenbankverbindung, nachdem der Tenant-Kontext gesetzt wurde. Der technische Mechanismus: Ein Doctrine DBAL-EventSubscriber reagiert auf den Verbindungsaufbau und wählt anhand des aktuellen Tenant-Kontexts die richtige Datenbank aus. Eine elegantere Lösung ist ein Decorator des ConnectionInterface, der bei jedem Verbindungsaufruf die Credentials des aktuellen Mandanten aus dem Kontext lädt und die physische Verbindung entsprechend aufbaut.

Ein häufiges Problem bei dynamischen Connections in Multi-Tenancy-Setups ist das Connection-Pooling. Standard-PHP-FPM hat kein persistentes Connection-Pooling zwischen Requests, aber bei Worker-Prozessen (RoadRunner, FrankenPHP) bleiben Verbindungen offen. Ohne explizites Verbindungs-Reset am Request-Anfang könnte ein Worker, der zuletzt für Mandant A eine Verbindung geöffnet hat, beim nächsten Request für Mandant B noch die alte Verbindung nutzen. Der Listener muss deshalb die Doctrine-Connection nach dem Tenant-Switch explizit als "uninitialized" markieren oder schließen, damit beim nächsten Query eine neue Verbindung mit den richtigen Credentials aufgebaut wird.

6. Row-Level-Security: tenant_id in allen Tabellen

Row-Level-Security ist die Strategie, bei der alle Mandanten dieselben Datenbanktabellen teilen, aber jede Zeile eine tenant_id-Spalte trägt. In PostgreSQL kann Row-Level-Security direkt auf Datenbankebene erzwungen werden — Symfony-seitig ist die Implementierung über Doctrine-Filter der sauberste Ansatz. Der Doctrine-Filter fügt automatisch ein WHERE tenant_id = :current_tenant_id zu allen betroffenen Queries hinzu. Das bedeutet: Kein Handler, kein Repository, kein Service muss manuell nach dem Mandanten filtern — der Filter macht das transparent für jede Query, die Doctrine ausführt.

Das Risiko bei Row-Level-Security ist der sogenannte "Filter-Bypass": Native SQL-Queries über EntityManager::getConnection()->executeQuery() umgehen den Doctrine-Filter, weil er auf DQL-Ebene arbeitet. Jeder Entwickler im Team muss wissen, dass native Queries explizit die tenant_id-Bedingung enthalten müssen. Code-Reviews sollten auf native SQL-Abfragen ohne tenant_id-Einschränkung prüfen. Eine statische Analyse-Regel (PHPStan Custom Rule) kann native Query-Aufrufe markieren und als Warnung ausgeben. Bei Multi-Tenancy-Systemen mit hohen Datenschutzanforderungen ist dieses Risiko ein starkes Argument für die separate-Datenbank-Strategie.

7. Doctrine Filter für automatische Tenant-Einschränkung

Doctrine SQL-Filter sind eine Erweiterungsmöglichkeit, die SQL-Bedingungen zu jeder Query hinzufügen, die eine bestimmte Entity betrifft. Ein Tenant-Filter implementiert das Interface SQLFilter und gibt im addFilterConstraint-Methode den SQL-Fragment zurück, der an die WHERE-Bedingung angehängt wird. Der Filter wird in der Doctrine-Konfiguration registriert und im Kernel-Event-Listener direkt nach dem Setzen des Tenant-Kontexts aktiviert. Ohne Aktivierung läuft der Filter nicht — das ermöglicht es, ihn für Administrative Zugriffe zu deaktivieren, die über alle Mandanten hinweg Daten lesen müssen.

Ein sorgfältig implementierter Doctrine-Filter für Multi-Tenancy prüft zunächst, ob die Entity das HasTenantInterface implementiert oder ein spezifisches Attribut trägt. Nur dann wird die Bedingung hinzugefügt — Shared-Tabellen wie country, currency oder configuration, die für alle Mandanten gelten, bleiben unberührt. Das Interface-Pattern ist dem Attribut-Pattern vorzuziehen, weil es statisch analysierbar ist und zur Compile-Zeit geprüft werden kann, ob eine Entity das Tenant-Filtering korrekt implementiert hat.


<?php

declare(strict_types=1);

namespace App\MultiTenancy\Infrastructure\Doctrine;

use App\MultiTenancy\Application\TenantContext;
use Doctrine\ORM\Mapping\ClassMetadata;
use Doctrine\ORM\Query\Filter\SQLFilter;

/**
 * Doctrine SQL filter that appends a tenant_id condition to every query
 * on entities that implement HasTenantInterface.
 */
final class TenantFilter extends SQLFilter
{
    /**
     * Inject TenantContext manually — SQLFilter cannot use constructor injection.
     */
    private ?TenantContext $context = null;

    public function setContext(TenantContext $context): void
    {
        $this->context = $context;
    }

    /**
     * Add WHERE tenant_id = :tenantId for all tenant-scoped entities.
     */
    public function addFilterConstraint(ClassMetadata $targetEntity, string $targetTableAlias): string
    {
        // Only filter entities that are explicitly marked as tenant-scoped
        if (!$targetEntity->reflClass->implementsInterface(HasTenantInterface::class)) {
            return '';
        }

        if ($this->context === null || !$this->context->hasTenant()) {
            return '';
        }

        $tenantId = (int) $this->context->getTenant()->id;

        // Return raw SQL condition — Doctrine appends this to every SELECT/UPDATE/DELETE
        return sprintf('%s.tenant_id = %d', $targetTableAlias, $tenantId);
    }
}

// Interface to mark entities as tenant-scoped — checked by the filter at runtime
interface HasTenantInterface
{
    public function getTenantId(): int;
    public function setTenantId(int $tenantId): void;
}

8. Migrations und Deployment für Multi-Tenant-Systeme

Datenbankmigrationen für Multi-Tenancy-Systeme sind erheblich komplexer als in Single-Tenant-Anwendungen. Bei der Separate-DB-Strategie muss jede Migration für alle Mandanten-Datenbanken ausgeführt werden — in der Regel sequenziell oder parallelisiert über Worker-Prozesse. Ein Migrationsskript iteriert über alle registrierten Mandanten, baut jeweils die entsprechende Datenbankverbindung auf und führt die ausstehenden Doctrine-Migrationen aus. Fehlgeschlagene Migrationen für einzelne Mandanten müssen protokolliert und manuell nachgezogen werden — eine automatische Rollback-Strategie über alle Mandanten ist praktisch nicht realisierbar.

Bei der Row-Level-Security-Strategie sind Migrationen einfacher, weil es nur eine Datenbank gibt. Aber neue Spalten oder Tabellen müssen immer mit einer geeigneten Standardbefüllung für bestehende Zeilen versehen werden. Ein neues Pflichtfeld in der projects-Tabelle braucht einen DEFAULT-Wert, der für alle bestehenden Mandanten-Daten sinnvoll ist. Zero-Downtime-Deployments in Multi-Tenancy-Systemen folgen dem Expand/Contract-Pattern: Zuerst die Tabelle erweitern (neue Spalte hinzufügen, nullable), dann den Code deployen, der die neue Spalte befüllt, dann die Spalte als NOT NULL markieren. Dieses dreistufige Vorgehen verhindert Ausfälle bei laufendem Betrieb.

9. Strategien im direkten Vergleich

Die Wahl der Multi-Tenancy-Strategie beeinflusst jeden Aspekt des Systems — von der Datenbankgröße bis zum Deployment-Prozess. Ein direkter Vergleich hilft bei der Entscheidung für das eigene Projekt.

Kriterium Separate Datenbank Separate Schema Row-Level (tenant_id)
Datenisolation Maximal Hoch (PostgreSQL) Filter-abhängig
Skalierbarkeit (Mandanten) Begrenzt (Infrastruktur) Mittel Hoch (tausende Mandanten)
Migrations-Aufwand Hoch (pro DB) Hoch (pro Schema) Niedrig (einmal)
Compliance / DSGVO Ideal Gut Anforderungsabhängig
Entwicklungsaufwand Mittel Mittel Hoch (Filter überall sicher)

In der Praxis kombinieren viele Multi-Tenancy-Systeme die Strategien: Enterprise-Kunden mit Compliance-Anforderungen bekommen eine separate Datenbank, Kleinkunden teilen eine gemeinsame Instanz mit Row-Level-Security. Diese Hybridlösung erfordert eine abstrahierte Datenschicht, die transparent mit beiden Ansätzen umgehen kann — aufwendiger, aber die flexibelste Option für SaaS-Modelle mit unterschiedlichen Kundensegmenten.

Mironsoft

Symfony SaaS-Architektur, Multi-Tenancy und skalierbare PHP-Backends

Multi-Tenancy-Architektur für euer Symfony-Projekt aufbauen?

Wir entwerfen und implementieren sichere Multi-Tenancy-Architekturen mit Symfony — von der Strategie-Wahl über Tenant-Resolver und Doctrine-Filter bis zu Migrations-Pipelines für beliebig viele Mandanten.

Strategie-Beratung

Separate DB, Schema oder Row-Level-Security — wir empfehlen die richtige Strategie für euer Skalierungsziel

Doctrine-Integration

Tenant-Filter, dynamische Connections und sichere Migrationsstrategie für wachsende Mandantenzahlen

Security-Audit

Prüfung bestehender Multi-Tenancy-Implementierungen auf Datenleck-Risiken und Filter-Bypässe

10. Zusammenfassung

Multi-Tenancy in Symfony ist eine durchgehende Architekturentscheidung, die von der Datenbankstrategie über den Tenant-Resolver bis zu Migrations-Pipelines reicht. Die Wahl zwischen separater Datenbank, separatem Schema und Row-Level-Security bestimmt Isolation, Skalierbarkeit und Betriebsaufwand für die gesamte Laufzeit des Projekts. Ein früh gesetzter Tenant-Kontext über KernelEvents::REQUEST mit hoher Priorität stellt sicher, dass alle Schichten des Systems mandantenspezifisch arbeiten. Doctrine-Filter für Row-Level-Security automatisieren die Datenisolation, erfordern aber konsequente Code-Reviews für native Queries.

Die größte Gefahr bei Multi-Tenancy-Implementierungen ist das nachträgliche Hinzufügen: Wenn eine Anwendung erst Single-Tenant entwickelt und später auf Multi-Tenancy umgestellt wird, sind Datenleck-Risiken erheblich. Das Richtige ist es, Multi-Tenancy von Anfang an als Architekturprinzip zu verankern — mit klaren Interfaces, testbaren Resolvern und einer globalen Doctrine-Filter-Strategie, die keine Ausnahmen kennt.

Symfony Multi-Tenancy — Das Wichtigste auf einen Blick

Früher Kontext

KernelEvents::REQUEST mit Priorität 100 — Tenant-Kontext vor Routing, Security und Controller setzen. Fail-Fast bei fehlendem Kontext.

Doctrine Filter

SQLFilter fügt WHERE tenant_id automatisch hinzu — nur für Entities mit HasTenantInterface. Native Queries manuell absichern.

Strategie wählen

Separate DB für Compliance-Anforderungen. Row-Level für tausende Kleinkunden. Hybrid für gemischte Kundensegmente.

Migrations

Expand/Contract-Pattern für Zero-Downtime. Bei Separate-DB Migrations-Skript für alle Mandanten-Datenbanken mit Fehlerprotokoll.

11. FAQ: Symfony Multi-Tenancy

1Was ist Multi-Tenancy in Symfony?
Eine Symfony-Instanz betreibt mehrere Kunden mit vollständiger Datenisolation. Keine eingebaute Lösung — erfordert Tenant-Resolver, Doctrine-Filter und Architektur-Entscheidungen von Anfang an.
2Welche Strategie wählen?
Separate DB für Compliance, Row-Level für tausende Kleinkunden, Hybrid für gemischte Kundensegmente. Die Entscheidung beeinflusst Skalierung, Migration und Betriebsaufwand dauerhaft.
3Was ist ein Tenant-Resolver?
Identifiziert den Mandanten aus dem HTTP-Request — via Subdomain, URL-Pfad oder Header. Schlägt den Mandanten in einer Registry nach und gibt ein Tenant-Value-Object zurück.
4Wie funktioniert Doctrine SQL-Filter?
addFilterConstraint() gibt SQL-Fragment zurück — Doctrine hängt es automatisch an jede WHERE-Bedingung der betroffenen Entity an. Filter wird pro Request mit Tenant-ID parametrisiert.
5Können Doctrine-Filter umgangen werden?
Ja — durch native SQL über getConnection()->executeQuery(). Diese müssen manuell mit tenant_id abgesichert werden. PHPStan Custom Rule kann native Queries automatisch markieren.
6Wann Tenant-Kontext setzen?
KernelEvents::REQUEST mit Priorität 100. So früh wie möglich — vor Routing, Security und Controller-Auflösung. Fail-Fast wenn kein Kontext und Service auf ihn zugreift.
7Migrationen für mehrere DBs?
Skript iteriert über alle Mandanten, baut jeweils DB-Verbindung auf und führt Doctrine-Migrationen aus. Fehlgeschlagene Mandanten werden protokolliert und manuell nachgezogen.
8Nachträglich Multi-Tenancy integrieren?
Risikoreich wegen Datenleck-Gefahr. Schritte: Resolver einführen, Entities mit Interface markieren, tenant_id befüllen, Filter aktivieren, Repositories auditieren, native Queries prüfen.
9Multi-Tenancy mit Symfony Messenger?
Tenant-Kontext als Stamp in Message speichern. Middleware im Worker stellt Kontext aus TenantStamp wieder her, bevor der Handler aufgerufen wird.
10Multi-Tenancy und DSGVO?
Hauptrisiko: Datenzugriff über Mandantengrenzen durch fehlerhafte Filter. Separate DBs eliminieren das. Bei Row-Level-Security regelmäßige Audits der Filter und nativer Queries Pflicht.