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.
Inhaltsverzeichnis
- 1. Warum Multi-Tenancy in Symfony eine eigene Architektur braucht
- 2. Die drei Datentrennung-Strategien im Vergleich
- 3. Tenant-Resolver: den aktuellen Mandanten identifizieren
- 4. Tenant-Kontext im Request Lifecycle verankern
- 5. Separate Datenbanken mit dynamischem Doctrine-Connection
- 6. Row-Level-Security: tenant_id in allen Tabellen
- 7. Doctrine Filter für automatische Tenant-Einschränkung
- 8. Migrations und Deployment für Multi-Tenant-Systeme
- 9. Strategien im direkten Vergleich
- 10. Zusammenfassung
- 11. FAQ
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.