Symfony Doctrine Multi-Tenant-Datenbankarchitektur: Shared Schema vs. Schema-pro-Tenant vs. DB-pro-Tenant - eine Entscheidungsmatrix für 2026

#Symfony Multi-Tenant
Sandor Farkas - Founder & Lead Developer at Wolf-Tech

Sandor Farkas

Gründer & Lead Developer

Experte für Softwareentwicklung und Legacy-Code-Optimierung

Die Multi-Tenancy-Entscheidung wird einmal getroffen. Anders als bei den meisten Architekturentscheidungen, bei denen du dich iterativ zu etwas Besserem vorarbeiten kannst, ist die Wahl zwischen Shared Schema, Schema-pro-Tenant und DB-pro-Tenant auf einem Symfony/Doctrine-Stack die Art von Entscheidung, die in 500.000 Zeilen Code eingebacken ist, bevor du merkst, dass du falsch gewählt hast. Bei 5.000 Tenants werden die Kosten einer Migration zwischen Strategien in Monaten gemessen - nicht in Sprints.

Dieser Guide richtet sich an Engineering-Teams europäischer B2B-SaaS-Unternehmen, die diese Entscheidung entweder zum ersten Mal treffen oder die Reibung einer früheren Wahl zu spüren beginnen. Die drei Patterns sind abstrakt gut dokumentiert; weniger gut dokumentiert ist, wie sie mit den spezifischen Zwängen interagieren, denen Symfony-Teams 2026 gegenüberstehen: Doctrines Connection-Modell, DSGVO-Audit-Anforderungen, die inzwischen echte Durchsetzungskraft haben, und der PostgreSQL-Connection-Pool-Spielraum, der bestimmt, ob deine Infrastrukturkosten linear bleiben.


Die drei Patterns, klar benannt

Vor der Entscheidungsmatrix eine knappe Beschreibung dessen, was jedes Modell im Doctrine-Kontext tatsächlich bedeutet - denn die Benennung wird im Ökosystem uneinheitlich verwendet.

Shared Schema (Diskriminator-Spalte): Alle Tenants teilen sich dieselbe PostgreSQL-Datenbank und dasselbe Schema. Eine tenant_id-Spalte wird jeder tenant-bezogenen Tabelle hinzugefügt. Doctrine Filters erzwingen Row-Level-Isolation auf der ORM-Ebene. Ein Connection-Pool bedient alle Tenants. Migrationen laufen einmal und betreffen alle Tenants gleichzeitig.

Schema-pro-Tenant (ein Schema, ein Cluster): Alle Tenants liegen auf demselben PostgreSQL-Cluster, aber jeder Tenant hat ein dediziertes Schema (z. B. tenant_7a3f.users, tenant_7a3f.orders). Eine EntityManager-Factory instanziiert pro Tenant einen separaten EntityManager, jeweils an sein Schema gebunden. PgBouncer-Sessions können pro Schema partitioniert werden. Migrationen laufen pro Tenant, begrenzt auf ein einzelnes Schema.

DB-pro-Tenant (eine Datenbank pro Tenant): Jeder Tenant erhält eine dedizierte PostgreSQL-Datenbank, bei kleineren Setups oft auf demselben Cluster, für Enterprise-Kunden potenziell auf separaten RDS- oder Managed-Postgres-Instanzen. Connection-Strings werden gespeichert und zur Laufzeit aufgelöst. Das ist das operativ komplexeste und am stärksten isolierte Modell.


Das Doctrine-EntityManager-pro-Tenant-Factory-Pattern

Um die Patterns Schema-pro-Tenant und DB-pro-Tenant zu verstehen, muss man verstehen, wie Doctrines EntityManager instanziiert wird, denn das Factory-Pattern ist der Dreh- und Angelpunkt beider Modelle.

In einer Standard-Symfony-Anwendung wird ein einzelner doctrine.orm.entity_manager-Service in config/packages/doctrine.yaml definiert und über alle Requests geteilt. Für Multi-Tenancy umgehst du das. Stattdessen baust du einen Factory-Service, der den aktuellen Tenant aus dem Request-Kontext auflöst (eine Subdomain, ein JWT-Claim, ein HTTP-Header) und einen EntityManager zurückgibt, der für die Connection dieses Tenants konfiguriert ist.

// src/Doctrine/TenantEntityManagerFactory.php
final class TenantEntityManagerFactory
{
    public function __construct(
        private readonly TenantResolver $tenantResolver,
        private readonly Connection $defaultConnection,
        private readonly Configuration $doctrineConfig,
    ) {}

    public function getEntityManager(): EntityManager
    {
        $tenant = $this->tenantResolver->current();

        $params = $this->defaultConnection->getParams();
        $params['dbname']       = $tenant->databaseName();   // DB-per-tenant
        // OR for schema-per-tenant:
        // $params['search_path'] = $tenant->schemaName();

        $connection = DriverManager::getConnection($params, $this->doctrineConfig);
        return new EntityManager($connection, $this->doctrineConfig);
    }
}

Die Factory wird dann als Service verwendet, der die direkte Injection von EntityManagerInterface in Repositories ersetzt. Das ist der architektonische Kern, der beide Nicht-Shared-Schema-Strategien in Symfony erst praktikabel macht - und es ist zugleich der Ursprung des Connection-Pool-Drucks, auf den wir weiter unten zurückkommen.


Automatisierte Migrationen pro Tenant-Schema

Einer der am meisten unterschätzten operativen Kostenpunkte jeder Nicht-Shared-Schema-Strategie ist die Ausführung von Migrationen zur Deployment-Zeit. Im Shared-Schema-Modell führst du php bin/console doctrine:migrations:migrate einmal aus und bist fertig. Bei Schema-pro-Tenant oder DB-pro-Tenant führst du es N-mal aus - einmal pro Tenant.

Bei 50 Tenants ist das eine Bash-Schleife. Bei 5.000 Tenants ist es eine Migrations-Queue mit Retry-Logik, Tenant-Locking, Rollback-Signalisierung und einer Alerting-Oberfläche für Teilausfälle.

Ein minimaler Ansatz für Schema-pro-Tenant:

// src/Command/MigrateAllTenantsCommand.php
#[AsCommand(name: 'app:migrations:migrate-all-tenants')]
final class MigrateAllTenantsCommand extends Command
{
    protected function execute(InputInterface $input, OutputInterface $output): int
    {
        foreach ($this->tenantRepository->findAll() as $tenant) {
            $em = $this->emFactory->getEntityManagerForTenant($tenant);

            try {
                $migrator = $this->buildMigrator($em->getConnection());
                $migrator->migrate(new MigrateToLatestCalculation());
                $output->writeln("Migrated: {$tenant->getId()}");
            } catch (\Throwable $e) {
                $this->logger->error("Migration failed for tenant {$tenant->getId()}: {$e->getMessage()}");
                // Decide: continue or halt deployment
            }
        }

        return Command::SUCCESS;
    }
}

Die Entscheidung, ob bei einer fehlgeschlagenen Tenant-Migration fortgefahren oder gestoppt wird, ist eine als Code-Kommentar getarnte Business-Entscheidung. Stoppen ist sicherer, bedeutet aber, dass ein kaputter Tenant ein Release für alle anderen blockiert. Fortfahren bedeutet, dass du Schema-Versionsdrift über Tenants hinweg tracken und Code pflegen musst, der gleichzeitig mit dem alten und dem neuen Schema kompatibel ist - dieselbe Herausforderung, vor der Blue-Green-Deployments auf Datenbankebene stehen.

Für Teams mit mehr als 200 Tenants wird das synchrone Ausführen von Migrationen aus einer Deployment-Pipeline Timeouts überschreiten. Du brauchst asynchrone Migration-Worker, eine Statustabelle und Health-Checks, die den Traffic-Rollover blockieren, bis alle Tenants auf der erwarteten Schema-Version sind.


Die Entscheidungsmatrix

Der folgende Vergleich stellt die drei Strategien entlang der Dimensionen gegenüber, die für europäische B2B-SaaS 2026 am wichtigsten sind.

DSGVO-Datenisolation

DimensionShared SchemaSchema-pro-TenantDB-pro-Tenant
Logische IsolationORM-Filter (umgehbar)Schema-GrenzeDatenbank-Grenze
Physische IsolationKeineKeine (gleicher Cluster)Ja (separate Instanz)
PITR-Restore-GranularitätNur ganzer ClusterNur ganzer ClusterPro Tenant
Umsetzung des Rechts auf LöschungKomplexes Multi-Table-DELETEDROP SCHEMA CASCADEDROP DATABASE
Eignung für Enterprise-AVVNiedrigMittelHoch

Der DSGVO-Druckpunkt 2026 ist längst nicht mehr nur das Recht auf Löschung. Enterprise-Kunden mit eigenen Datenschutzbeauftragten verlangen vertragliche Garantien zur logischen und physischen Datentrennung. Die Shared-Schema-Antwort auf "Sind unsere Datensätze physisch von anderen Kunden getrennt?" lautet immer "Nein" - was das Modell für regulierte Käufer in FinTech, Healthcare und dem öffentlichen Sektor ausschließt, egal wie stark die Zugriffskontrollen sind.

Schema-pro-Tenant stellt die meisten SMB- und Mid-Market-Käufer zufrieden. DB-pro-Tenant ist das, was Enterprise- und Kunden aus regulierten Branchen zunehmend verlangen.

Migrationskomplexität bei Schema-Evolution

DimensionShared SchemaSchema-pro-TenantDB-pro-Tenant
Migrationskosten beim DeployO(1)O(N Tenants)O(N Tenants)
Risiko partieller MigrationszuständeNiedrigMittelHoch
Schema-Versionsdrift möglichNeinJaJa
Rückwärtskompatible Migrationen nötigEmpfohlenErforderlichErforderlich
Rollback-KomplexitätEinfachKomplexSehr komplex

Shared Schema gewinnt bei der Migrationskomplexität eindeutig. Eine Migration, ein Ergebnis, kein Drift. Bei jeder Schema-Splitting-Strategie akzeptierst du eine neue Klasse von Produktionsproblemen: Tenants auf unterschiedlichen Schema-Versionen, die gegen denselben Anwendungscode laufen. Das zwingt dich, immer rückwärtskompatible Migrationen zu schreiben (nie umbenennen, immer add-then-remove) und ein Tenant-Schema-Versionsregister zu pflegen.

Connection-Pool-Spielraum

Tenant-AnzahlShared SchemaSchema-pro-TenantDB-pro-Tenant
100 Tenants~20-50 Connections~50-150~100-300
500 Tenants~20-50 Connections~250-750~500-1.500
5.000 Tenants~20-50 ConnectionsErfordert aggressives PoolingOhne Serverless Postgres unpraktikabel
PgBouncer erforderlichNeinJa (empfohlen)Ja (zwingend)

Hier hat Schema-pro-Tenant eine versteckte Klippe. Das EntityManager-Factory-Pattern erzeugt neue Connections pro Tenant pro Request, sofern du keinen aggressiven Connection-Pool davorschaltest. PgBouncer im Transaction-Mode löst das, führt aber Timing-Probleme mit SET search_path und Doctrine ein - der Search-Path muss nach jedem Connection-Checkout gesetzt werden, was einen eigenen postConnect-Event-Listener erfordert:

// src/Doctrine/SearchPathListener.php
final class SearchPathListener implements EventSubscriberInterface
{
    public function postConnect(PostConnectEventArgs $args): void
    {
        $tenant = $this->tenantResolver->current();
        $args->getConnection()->executeStatement(
            "SET search_path TO {$tenant->schemaName()}, public"
        );
    }

    public static function getSubscribedEvents(): array
    {
        return [Events::postConnect => 'postConnect'];
    }
}

Ohne diesen Listener tragen aus PgBouncers Pool recycelte Connections den search_path der vorherigen Session und lesen stillschweigend die Daten des falschen Tenants. Das ist die Art von Bug, die im Testing nicht auftaucht, den Code-Review passiert und in Produktion einen DSGVO-Vorfall verursacht.

Jenseits von 500 Tenants auf Schema-pro-Tenant wird die Connection-Arithmetik ohne Neons Serverless-Connection-Pooler oder einen eigenen Connection-Multiplexer untragbar. DB-pro-Tenant jenseits von 500 Tenants erfordert horizontales Sharding der Tenant-Datenbanken über mehrere Cluster - eine DevOps-Investition, die nur für Enterprise-SaaS mit vertraglichen Uptime- und Isolationsanforderungen gerechtfertigt ist.

Point-in-Time-Restore-Granularität

PITR-Granularität ist ein operatives Detail, das erst dann eine Rolle spielt, wenn etwas schiefgeht - und dann spielt es eine enorme Rolle.

Shared Schema: Ein Restore des Clusters stellt alle Tenants gleichzeitig wieder her. Eine verpatzte Migration, die Daten eines einzelnen Tenants korrumpiert, erfordert die Wiederherstellung des gesamten Clusters und das Nachspielen von Transaktionen - bei großen Datenmengen in Stunden gemessen und mit Auswirkung auf jeden Kunden.

Schema-pro-Tenant: Dieselbe Exponierung. Die Schemas liegen auf einem Cluster. Ein versehentliches DROP SCHEMA CASCADE erfordert weiterhin einen Restore auf Cluster-Ebene.

DB-pro-Tenant: Jede Tenant-Datenbank kann unabhängig wiederhergestellt werden. Das ist das einzige Pattern, das es erlaubt, einen einzelnen Tenant auf einen Zeitpunkt zurückzusetzen, ohne irgendeinen anderen Tenant zu beeinflussen. Für SaaS-Produkte mit Enterprise-SLAs ist diese Fähigkeit oft eine vertragliche Anforderung.


Performance-Klippen jenseits von 500 Tenants

Jedes Pattern hat einen spezifischen Wendepunkt, an dem die Performance nichtlinear degradiert.

Shared Schema: Die Klippe ist die Effizienz des Query-Planners. Mit einem tenant_id-Diskriminator auf jeder Tabelle schätzt der Query-Planner Zeilen über alle Tenants hinweg. Bei 50 Millionen Zeilen in einer orders-Tabelle über 5.000 Tenants produziert selbst ein zusammengesetzter Index auf (tenant_id, created_at) Query-Pläne, die degradieren, weil die Tabellenstatistiken immer weniger repräsentativ für die Datenverteilung eines einzelnen Tenants sind. Partition-by-List auf tenant_id (PostgreSQL 10+) hilft, aber eine live geschaltete Shared-Schema-Tabelle zu partitionieren ist selbst eine erhebliche Migrationsoperation.

Schema-pro-Tenant: Die Klippe ist Connection-Pool-Erschöpfung, verstärkt durch pg_catalog-Bloat. PostgreSQL speichert Schema-Metadaten in pg_catalog. Bei 10.000 Schemas auf einem einzelnen Cluster werden Katalogabfragen (genutzt von Doctrines Schema-Manager und von ANALYZE) messbar langsamer. Auto-Analyze hat Mühe, die Statistiken über Tausende Schemas aktuell zu halten.

DB-pro-Tenant: Die Klippe ist der Cluster-Management-Overhead. Bei 1.000 Tenant-Datenbanken auf einer einzelnen PostgreSQL-Instanz werden autovacuum, WAL-Management und Shared-Buffer-Allokation unvorhersehbar. Die meisten Teams wechseln in dieser Größenordnung zu einem Sharded-Cluster-Modell, bei dem Tenant-Datenbanken über mehrere PostgreSQL-Instanzen verteilt und über einen Connection-Proxy geroutet werden.


Ein Pattern wählen: der vereinfachte Entscheidungsbaum

Starte mit Shared Schema, wenn du weniger als 100 Tenants hast, keine nahen Pläne für regulierte Branchen, deine Kunden SMBs ohne Datenschutzbeauftragte sind, die nach physischer Isolation fragen, und deine Hauptsorge Time-to-Market ist. Akzeptiere, dass eine spätere Migration ansteht, wenn du dich in Richtung Upmarket bewegst.

Nutze Schema-pro-Tenant, wenn du zwischen 50 und 2.000 Tenants liegst, Mid-Market-Kunden hast, die im Procurement nach Datentrennung fragen, du eine saubere Umsetzung des Rechts auf Löschung brauchst und bereit bist, in die PgBouncer-Konfiguration und den Pro-Tenant-Migrations-Runner zu investieren. Das ist 2026 der richtige Default für europäische B2B-SaaS auf der grünen Wiese.

Nutze DB-pro-Tenant, wenn du an Enterprises oder regulierte Branchen verkaufst, in denen physische Isolation eine vertragliche Anforderung ist, du Pro-Tenant-PITR brauchst oder ein Produkt baust, bei dem Enterprise-Kunden irgendwann dedizierte Infrastruktur verlangen werden. Dimensioniere deine DevOps-Investition entsprechend oder nutze einen Managed Service (Neon, PlanetScale, RDS), bei dem die Kosten pro Datenbank vorhersehbar sind.


DSGVO-Pflichten je Architektur

Nach Art. 30 DSGVO musst du ein Verzeichnis von Verarbeitungstätigkeiten führen. Nach Art. 17 und 18 musst du auf Lösch- und Einschränkungsanfragen reagieren. Die gewählte Architektur bestimmt direkt, wie teuer die operative Erfüllung dieser Pflichten ist.

Bei Shared Schema erfordert das Recht auf Löschung, jede Tabelle mit einem tenant_id-Fremdschlüssel zu identifizieren und koordinierte Löschungen oder Anonymisierungen auszuführen. Das ist skriptbar, aber die Angriffsfläche wächst mit jeder neuen Tabelle, die irgendein Entwickler hinzufügt. Eine übersehene Tabelle ist eine Compliance-Lücke.

Bei Schema-pro-Tenant ist die Löschung ein DROP SCHEMA tenant_7a3f CASCADE - ein Statement, atomar, vollständig. Die Schema-Grenze gibt dir außerdem einen klaren Perimeter für das Daten-Mapping nach Art. 30: Du kannst Verarbeitungsverzeichnisse pro Tenant durch Inspektion des Schemas generieren.

Bei DB-pro-Tenant ist die Isolation am stärksten. Du kannst Enterprise-Kunden Read-only-Datenbank-Credentials für ihren eigenen Audit-Zugriff bereitstellen - eine Anfrage, die Datenschutzbeauftragte in FinTech und Healthcare zunehmend als Standard-Due-Diligence stellen.


Praktische nächste Schritte

Wenn sich deine Symfony-Anwendung den hier beschriebenen Wendepunkten nähert - sei es ein Enterprise-Kunde, der zum ersten Mal nach Datenisolation fragt, eine Migration, die im Deployment zu lange läuft, oder Connection-Pool-Alarme zur Spitzenlast - ist das die Architekturentscheidung, die du richtig treffen solltest, bevor Skalierung sie teuer macht.

Wolf-Tech bietet SaaS-Architekturberatung und technische Begleitung für genau diese Entscheidungen: Pre-Migration-Assessment, Implementierung der Doctrine-EntityManager-Factory, PgBouncer-Konfiguration und Pro-Tenant-Migrations-Runner, die zu deiner Deployment-Pipeline passen. Wir haben diese Transition bei mehreren Symfony-Produkten begleitet, bei Tenant-Zahlen von 20 bis zu mehreren Tausend.

Um zu besprechen, wo dein aktuelles Setup steht und wie der Migrationspfad aussieht, melde dich unter hello@wolf-tech.io oder über wolf-tech.io.


Häufig gestellte Fragen

Kann ich mit Shared Schema starten und später zu Schema-pro-Tenant migrieren?

Ja, aber die Komplexität ist proportional zu Tenant-Anzahl und Datenvolumen. Unter 100 Tenants ist die Migration ein überschaubares Projekt. Bei 1.000+ Tenants erfordert sie sorgfältige Planung, Parallelbetriebsphasen und robuste Rollback-Prozeduren. Je früher der Wechsel, desto geringer die Kosten.

Unterstützt Doctrine Schema-pro-Tenant nativ?

Nicht nativ. Multi-Tenancy erfordert das oben beschriebene EntityManager-Factory-Pattern kombiniert mit einem Symfony-Service, der den aktuellen Tenant aus dem Request-Kontext auflöst. Es gibt kein First-Class-Doctrine-Bundle für Multi-Tenancy - du baust es selbst oder nutzt ein Community-Paket und passt es an deine Bedürfnisse an.

Wie groß ist der Performance-Unterschied für typische B2B-SaaS-Workloads unter 500 Tenants?

Für Standard-OLTP-Muster, die von Single-Tenant-Queries dominiert werden, ist der Performance-Unterschied zwischen den drei Patterns unter 500 Tenants vernachlässigbar. Die Patterns divergieren bei Skalierung und bei Cross-Tenant-Query-Mustern. Admin-Dashboards und plattformweite Analysen sind in Shared Schema am schnellsten und in DB-pro-Tenant am komplexesten, wo sie Scatter-Gather-Queries über Datenbanken hinweg erfordern.

Wie interagiert der PgBouncer-Transaction-Mode mit Doctrine Prepared Statements?

Der PgBouncer-Transaction-Mode unterstützt keine benannten Prepared Statements, die Doctrine standardmäßig verwendet. Du musst entweder den PgBouncer-Session-Mode nutzen (der das Search-Path-Problem löst, aber die Pool-Effizienz reduziert) oder benannte Prepared Statements in Doctrines DBAL-Konfiguration deaktivieren. Das ist typischerweise das erste Produktionsproblem, auf das Teams beim Wechsel zu Schema-pro-Tenant stoßen, und es lohnt sich, es vor dem ersten Produktions-Deployment zu lösen statt danach.