Multi-Tenant-Architekturen: Eine Codebasis, viele Kunden
Sie haben eine erfolgreiche Business-App – und plötzlich wollen 50 Kunden sie nutzen. Kopieren Sie den Code 50-mal? Natürlich nicht. Multi-Tenant-Architektur ist die Antwort. Nach dutzenden mandantenfähigen Systemen zeige ich, welcher Ansatz wann der richtige ist.
TL;DR – Die Kurzfassung
- Shared Database + Tenant-ID: Einfachster Start, aber Isolation nur auf Applikationsebene
- Shared Database + Separate Schemas: Bessere Isolation, komplexere Migrations
- Database per Tenant: Maximale Isolation, höchster Aufwand
- Hybrid: Kleine Kunden shared, Enterprise-Kunden isoliert
- Goldene Regel: Tenant-Context muss unveränderlich durch jeden Request fließen
Was ist Multi-Tenancy?
Multi-Tenancy bedeutet: Eine Applikation, eine Codebasis, ein Deployment – aber viele voneinander isolierte Kunden (Tenants). Jeder Tenant sieht nur seine eigenen Daten, als wäre die App exklusiv für ihn.
┌─────────────────────────────────────────────────────────────────┐
│ MULTI-TENANT vs. SINGLE-TENANT │
├─────────────────────────────────────────────────────────────────┤
│ │
│ SINGLE-TENANT (pro Kunde eine Instanz) │
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
│ │ App │ │ App │ │ App │ │
│ │ Kunde A │ │ Kunde B │ │ Kunde C │ → 3x Wartung │
│ │ DB A │ │ DB B │ │ DB C │ → 3x Deployments │
│ └─────────┘ └─────────┘ └─────────┘ → 3x Server │
│ │
│ MULTI-TENANT (eine Instanz für alle) │
│ ┌─────────────────────────────────────┐ │
│ │ Applikation │ │
│ │ ┌───────┬───────┬───────┐ │ → 1x Wartung │
│ │ │ A │ B │ C │ Tenants │ → 1x Deployment │
│ │ └───────┴───────┴───────┘ │ → 1x Server │
│ └─────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────┘
Die drei Haupt-Strategien
| Strategie | Isolation | Komplexität | Kosten | Use Case |
|---|---|---|---|---|
| Shared DB + Tenant-ID | Niedrig | Niedrig | Niedrig | SaaS-Startups, <100 Tenants |
| Shared DB + Schema/Tenant | Mittel | Mittel | Mittel | Regulierte Branchen |
| Database per Tenant | Hoch | Hoch | Hoch | Enterprise, Compliance |
Strategie 1: Shared Database mit Tenant-ID
Der einfachste und häufigste Ansatz: Alle Tenants teilen sich eine Datenbank. Jede Tabelle hat eine tenant_id-Spalte.
Datenbank-Schema
-- Tenants-Tabelle
CREATE TABLE tenants (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
slug VARCHAR(50) UNIQUE NOT NULL, -- für Subdomain: acme.app.de
name VARCHAR(255) NOT NULL,
plan VARCHAR(50) DEFAULT 'starter',
settings JSONB DEFAULT '{}',
created_at TIMESTAMP DEFAULT NOW(),
-- Constraints
CONSTRAINT valid_slug CHECK (slug ~ '^[a-z0-9-]+$')
);
-- Jede Business-Tabelle hat tenant_id
CREATE TABLE projects (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id),
name VARCHAR(255) NOT NULL
-- ...weitere Felder
);
-- Hinweis: ON DELETE CASCADE ist bei SaaS oft okay, bei Enterprise/Compliance
-- häufig unerwünscht (Audit-Trail, Datenaufbewahrungspflichten).
-- Composite Index für alle Tenant-Queries
CREATE INDEX idx_projects_tenant ON projects(tenant_id, id);
CREATE INDEX idx_projects_tenant_name ON projects(tenant_id, name);
PHP: Tenant-Context als Service
<?php
declare(strict_types=1);
final class Tenant
{
public function __construct(
public readonly string $id,
public readonly string $slug,
public readonly string $name,
public readonly string $plan,
public readonly array $settings,
) {}
}
final class TenantContext
{
private ?Tenant $tenant = null;
public function set(Tenant $tenant): void
{
if ($this->tenant !== null) {
throw new LogicException('Tenant already set for this request');
}
$this->tenant = $tenant;
}
public function get(): Tenant
{
if ($this->tenant === null) {
throw new RuntimeException('No tenant in context');
}
return $this->tenant;
}
public function getId(): string
{
return $this->get()->id;
}
public function has(): bool
{
return $this->tenant !== null;
}
// WICHTIG: Bei long-lived Prozessen (RoadRunner, Swoole, Worker)
// muss der Context am Request-Ende resettet werden!
public function reset(): void
{
$this->tenant = null;
}
}
Wichtig bei Long-Lived Prozessen: Bei PHP-FPM ist jeder Request isoliert. Bei RoadRunner, Swoole oder Queue-Workern bleibt der Prozess bestehen – reset() muss am Request-Ende aufgerufen werden, sonst "klebt" Tenant A im Memory.
Middleware: Tenant aus Subdomain ermitteln
<?php
final class TenantMiddleware
{
public function __construct(
private TenantRepository $tenants,
private TenantContext $context,
) {}
public function __invoke(Request $request, callable $next): Response
{
$slug = $this->extractSlug($request);
if ($slug === null) {
return new Response(400, 'Tenant not identifiable');
}
$tenant = $this->tenants->findBySlug($slug);
if ($tenant === null) {
return new Response(404, 'Tenant not found');
}
if ($tenant->isSuspended()) {
return new Response(403, 'Account suspended');
}
$this->context->set($tenant);
return $next($request);
}
private function extractSlug(Request $request): ?string
{
$host = strtolower($request->getHost()); // Immer lowercase!
// acme.meineapp.de → acme
if (preg_match('/^([a-z0-9-]+)\.meineapp\.de$/', $host, $m)) {
return $m[1];
}
return null; // Public Web: NUR aus Host/Subdomain!
}
}
Security-Warnung zu X-Tenant-ID Header: Niemals den Tenant direkt aus einem freien HTTP-Header lesen! Ein Angreifer kann X-Tenant-ID: anderer-kunde setzen und fremde Daten sehen.
- Public Web: Tenant immer aus Host/Subdomain
- APIs: Tenant aus JWT-Claims oder API-Key→Tenant Lookup, nie aus freiem Header
- Internes API-Gateway: Header nur wenn technisch erzwungen (mTLS, private Network, WAF-Rule)
Repository mit automatischer Tenant-Filterung
<?php
abstract class TenantAwareRepository
{
public function __construct(
protected PDO $db,
protected TenantContext $tenant,
) {}
protected function tenantCondition(): string
{
return 'tenant_id = :tenant_id';
}
protected function tenantParams(): array
{
return ['tenant_id' => $this->tenant->getId()];
}
}
final class ProjectRepository extends TenantAwareRepository
{
public function findAll(): array
{
$sql = "SELECT * FROM projects
WHERE {$this->tenantCondition()}
ORDER BY created_at DESC";
$stmt = $this->db->prepare($sql);
$stmt->execute($this->tenantParams());
return $stmt->fetchAll(PDO::FETCH_ASSOC);
}
public function findById(string $id): ?array
{
// KRITISCH: Immer tenant_id prüfen, auch bei ID-Lookup!
$sql = "SELECT * FROM projects
WHERE id = :id AND {$this->tenantCondition()}";
$stmt = $this->db->prepare($sql);
$stmt->execute(['id' => $id, ...$this->tenantParams()]);
return $stmt->fetch(PDO::FETCH_ASSOC) ?: null;
}
public function create(array $data): string
{
// tenant_id automatisch setzen
$data['tenant_id'] = $this->tenant->getId();
$data['id'] = Uuid::v4();
$columns = implode(', ', array_keys($data));
$placeholders = ':' . implode(', :', array_keys($data));
$sql = "INSERT INTO projects ({$columns}) VALUES ({$placeholders})";
$stmt = $this->db->prepare($sql);
$stmt->execute($data);
return $data['id'];
}
}
Sicherheitsnetz: Row-Level Security (PostgreSQL)
-- Zusätzliche Absicherung auf DB-Ebene
ALTER TABLE projects ENABLE ROW LEVEL SECURITY;
-- Policy: User sieht nur eigenen Tenant (fail-closed!)
CREATE POLICY tenant_isolation ON projects
USING (
tenant_id = current_setting('app.current_tenant', true)::uuid
);
Fail-Closed: Der zweite Parameter true bei current_setting() verhindert einen Error wenn das Setting fehlt – stattdessen gibt's NULL. Da NULL = uuid immer false ist, wird der Zugriff blockiert. Ohne true fliegt dir ein Error um die Ohren.
// Transaction-Wrapper Pattern für RLS
$db->beginTransaction();
try {
$db->exec("SET LOCAL app.current_tenant = " . $db->quote($tenantId));
// ... Queries hier ...
$db->commit();
} catch (\Throwable $e) {
$db->rollBack();
throw $e;
}
Vorteil RLS: Selbst wenn ein Bug in der Applikation die tenant_id-Prüfung vergisst, blockiert PostgreSQL den Zugriff. Defense in Depth.
Wichtig: SET LOCAL statt SET verwenden! Bei Connection Pooling (z.B. PgBouncer) könnte ein SET ohne LOCAL die Session "vergiften" und der nächste Request sieht den falschen Tenant.
Strategie 2: Separate Schemas pro Tenant
Jeder Tenant bekommt ein eigenes PostgreSQL-Schema innerhalb derselben Datenbank. Bessere Isolation, aber komplexere Migrations.
Schema-Erstellung bei Tenant-Onboarding
<?php
final class TenantProvisioner
{
public function __construct(
private PDO $db,
private string $templateSchema = 'tenant_template',
) {}
public function provision(string $tenantSlug): void
{
$schema = $this->schemaName($tenantSlug);
$this->db->beginTransaction();
try {
// Schema aus Template klonen
$this->db->exec("CREATE SCHEMA {$schema}");
// Alle Tabellen aus Template kopieren
$tables = $this->getTemplateTables();
foreach ($tables as $table) {
$this->db->exec(
"CREATE TABLE {$schema}.{$table}
(LIKE {$this->templateSchema}.{$table} INCLUDING ALL)"
);
}
$this->db->commit();
} catch (Throwable $e) {
$this->db->rollBack();
throw new TenantProvisioningException($e->getMessage(), 0, $e);
}
}
public function schemaName(string $slug): string
{
// tenant_acme, tenant_globex, etc.
return 'tenant_' . preg_replace('/[^a-z0-9]/', '_', $slug);
}
// PRODUKTIONS-HINWEIS: Besser eine Whitelist/Mapping aus der DB!
// Schema-Namen sollten nicht dynamisch aus User-Input gebaut werden.
// Stattdessen: tenants.schema_name Spalte, die bei Onboarding gesetzt wird.
private function getTemplateTables(): array
{
$sql = "SELECT tablename FROM pg_tables
WHERE schemaname = :schema";
$stmt = $this->db->prepare($sql);
$stmt->execute(['schema' => $this->templateSchema]);
return $stmt->fetchAll(PDO::FETCH_COLUMN);
}
}
Search Path dynamisch setzen
<?php
final class SchemaBasedTenantMiddleware
{
public function __construct(
private PDO $db,
private TenantContext $context,
private TenantProvisioner $provisioner,
) {}
public function __invoke(Request $request, callable $next): Response
{
$tenant = $this->context->get();
$schema = $this->provisioner->schemaName($tenant->slug);
// Search Path auf Tenant-Schema setzen
$this->db->exec("SET search_path TO {$schema}, public");
// Ab jetzt gehen alle Queries automatisch ins richtige Schema
return $next($request);
}
}
Achtung bei PgBouncer: SET search_path ist session-level und funktioniert nicht zuverlässig mit PgBouncer im transaction Pooling-Modus. Alternativen:
sessionPooling-Modus (weniger performant, mehr Connections)- Alle Queries explizit mit Schema-Prefix:
SELECT * FROM {$schema}.projects - Separate Pools pro Tenant mit
search_pathin der Connection-Config
Migrations für alle Schemas
<?php
final class MultiSchemasMigrator
{
public function migrate(string $migrationSql): void
{
$schemas = $this->getAllTenantSchemas();
foreach ($schemas as $schema) {
$this->db->exec("SET search_path TO {$schema}");
try {
$this->db->exec($migrationSql);
echo "✓ {$schema}\n";
} catch (PDOException $e) {
echo "✗ {$schema}: {$e->getMessage()}\n";
// Entscheidung: Abbrechen oder weitermachen?
}
}
}
private function getAllTenantSchemas(): array
{
$sql = "SELECT schema_name FROM information_schema.schemata
WHERE schema_name LIKE 'tenant_%'";
return $this->db->query($sql)->fetchAll(PDO::FETCH_COLUMN);
}
}
Strategie 3: Database per Tenant
Maximale Isolation: Jeder Tenant bekommt seine eigene Datenbank. Typisch für Enterprise-Kunden mit Compliance-Anforderungen oder wenn Kunden ihre eigene DB-Instanz verlangen.
Connection Pool / Factory
<?php
final class TenantDatabaseFactory
{
private array $connections = [];
public function __construct(
private TenantContext $context,
private TenantRepository $tenants,
) {}
public function getConnection(): PDO
{
$tenantId = $this->context->getId();
if (!isset($this->connections[$tenantId])) {
$this->connections[$tenantId] = $this->createConnection($tenantId);
}
return $this->connections[$tenantId];
}
private function createConnection(string $tenantId): PDO
{
$tenant = $this->tenants->findById($tenantId);
$config = $tenant->getDatabaseConfig();
// Jeder Tenant hat eigene DB-Credentials
$dsn = sprintf(
'pgsql:host=%s;port=%d;dbname=%s',
$config['host'],
$config['port'],
$config['database']
);
return new PDO($dsn, $config['user'], $config['password'], [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]);
}
}
Tenant-Datenbank provisionieren
<?php
final class DatabasePerTenantProvisioner
{
public function __construct(
private PDO $adminDb, // Verbindung mit CREATE DATABASE Rechten
private string $templateDb = 'app_template',
) {}
public function provision(Tenant $tenant): array
{
$dbName = 'app_' . $tenant->slug;
$dbUser = 'user_' . $tenant->slug;
$dbPass = bin2hex(random_bytes(16));
// Datenbank aus Template erstellen
$this->adminDb->exec(
"CREATE DATABASE {$dbName} TEMPLATE {$this->templateDb}"
);
// Eigener DB-User für Tenant
$this->adminDb->exec(
"CREATE USER {$dbUser} WITH PASSWORD '{$dbPass}'"
);
$this->adminDb->exec(
"GRANT ALL PRIVILEGES ON DATABASE {$dbName} TO {$dbUser}"
);
return [
'host' => 'localhost',
'port' => 5432,
'database' => $dbName,
'user' => $dbUser,
'password' => $dbPass, // Verschlüsselt speichern!
];
}
}
⚠️ Demo-Code – nicht produktionsreif! Das DDL hier zeigt nur das Prinzip. In Produktion:
- Kein String-Interpolieren: Identifier mit
quote_ident()oderformat('%I', ...)escapen - Passwörter: Niemals in SQL-Strings! Externes Auth (LDAP, IAM) oder Admin-Tool nutzen
- Provisioning: Besser über dediziertes Admin-Tool, Terraform, oder Migration-Service – nicht aus der App heraus
Hybrid-Ansatz: Das Beste aus beiden Welten
In der Praxis oft am sinnvollsten: Kleine Tenants shared, große Tenants isoliert.
<?php
final class HybridTenantResolver
{
public function __construct(
private PDO $sharedDb,
private TenantDatabaseFactory $dedicatedFactory,
) {}
public function getConnection(Tenant $tenant): PDO
{
return match ($tenant->plan) {
'starter', 'professional' => $this->sharedDb,
'enterprise' => $this->dedicatedFactory->getConnection(),
default => $this->sharedDb,
};
}
}
Tenant-spezifische Anpassungen
Feature Flags pro Tenant
<?php
final class TenantFeatures
{
public function __construct(
private TenantContext $context,
) {}
public function isEnabled(string $feature): bool
{
$tenant = $this->context->get();
// Plan-basierte Features
$planFeatures = [
'starter' => ['basic_reports'],
'professional' => ['basic_reports', 'api_access', 'custom_fields'],
'enterprise' => ['basic_reports', 'api_access', 'custom_fields',
'sso', 'audit_log', 'white_label'],
];
// Tenant-spezifische Overrides aus settings
$overrides = $tenant->settings['features'] ?? [];
$enabled = $planFeatures[$tenant->plan] ?? [];
$enabled = array_merge($enabled, $overrides['enable'] ?? []);
$enabled = array_diff($enabled, $overrides['disable'] ?? []);
return in_array($feature, $enabled, true);
}
}
// Nutzung
if ($features->isEnabled('api_access')) {
// API-Routen registrieren
}
White-Labeling: Custom Branding
<?php
final class TenantBranding
{
public function __construct(
private TenantContext $context,
) {}
public function getLogo(): string
{
$tenant = $this->context->get();
return $tenant->settings['branding']['logo']
?? '/assets/default-logo.svg';
}
public function getPrimaryColor(): string
{
return $this->context->get()->settings['branding']['primary_color']
?? '#1a355e';
}
public function getEmailFrom(): string
{
$tenant = $this->context->get();
// Enterprise: Custom Domain
if (isset($tenant->settings['email']['from'])) {
return $tenant->settings['email']['from'];
}
// Standard: noreply@tenant.meineapp.de
return "noreply@{$tenant->slug}.meineapp.de";
}
}
Security-Kritische Aspekte
1. Niemals Tenant-ID aus User-Input
// ❌ NIEMALS SO
$tenantId = $_GET['tenant_id'];
// ✅ Immer aus verifizierter Quelle
$tenantId = $this->context->getId(); // Gesetzt durch Middleware
2. Jeden Datenzugriff absichern
// ❌ Gefährlich: Nur ID prüfen
public function delete(string $projectId): void
{
$this->db->exec("DELETE FROM projects WHERE id = '{$projectId}'");
}
// ✅ Sicher: Immer auch tenant_id
public function delete(string $projectId): void
{
$sql = "DELETE FROM projects WHERE id = :id AND tenant_id = :tenant_id";
$stmt = $this->db->prepare($sql);
$stmt->execute([
'id' => $projectId,
'tenant_id' => $this->context->getId(),
]);
if ($stmt->rowCount() === 0) {
throw new NotFoundException('Project not found');
}
}
3. Cross-Tenant-Leaks in Logs verhindern
<?php
final class TenantAwareLogger implements LoggerInterface
{
public function __construct(
private LoggerInterface $inner,
private TenantContext $context,
) {}
public function info(string $message, array $context = []): void
{
// Tenant-ID automatisch in jeden Log-Eintrag
$context['tenant_id'] = $this->context->has()
? $this->context->getId()
: 'no-tenant';
$this->inner->info($message, $context);
}
// ... weitere Methoden
}
4. Background Jobs: Tenant-Context wiederherstellen
<?php
// Job erstellen: Tenant-ID mitspeichern
$queue->push([
'job' => 'SendReport',
'tenant_id' => $this->context->getId(), // WICHTIG!
'report_id' => $reportId,
]);
// Job verarbeiten
final class JobProcessor
{
public function process(array $job): void
{
// Tenant-Context für diesen Job setzen
$tenant = $this->tenants->findById($job['tenant_id']);
$this->context->set($tenant);
// Jetzt kann der Job sicher ausgeführt werden
$handler = $this->resolveHandler($job['job']);
$handler->handle($job);
}
}
Performance-Optimierungen
Tenant-Aware Caching
<?php
final class TenantCache
{
public function __construct(
private CacheInterface $cache,
private TenantContext $context,
) {}
public function get(string $key): mixed
{
return $this->cache->get($this->prefixKey($key));
}
public function set(string $key, mixed $value, int $ttl = 3600): void
{
$this->cache->set($this->prefixKey($key), $value, $ttl);
}
private function prefixKey(string $key): string
{
// tenant:acme:projects:list
return "tenant:{$this->context->get()->slug}:{$key}";
}
public function invalidateTenant(): void
{
// Alle Cache-Keys dieses Tenants löschen
$pattern = "tenant:{$this->context->get()->slug}:*";
$this->cache->deletePattern($pattern);
}
}
Connection Pooling bei Database-per-Tenant
// PgBouncer Config für Multi-Tenant
// /etc/pgbouncer/pgbouncer.ini
[databases]
app_acme = host=localhost dbname=app_acme
app_globex = host=localhost dbname=app_globex
; ... oder dynamisch via auth_query
[pgbouncer]
pool_mode = transaction
max_client_conn = 1000
default_pool_size = 20
Testing von Multi-Tenant-Code
<?php
final class ProjectRepositoryTest extends TestCase
{
private TenantContext $context;
private ProjectRepository $repo;
protected function setUp(): void
{
// Test-Tenant erstellen
$this->context = new TenantContext();
$this->context->set(new Tenant(
id: 'test-tenant-123',
slug: 'test',
name: 'Test Company',
plan: 'professional',
settings: [],
));
$this->repo = new ProjectRepository($this->db, $this->context);
}
public function testCannotAccessOtherTenantData(): void
{
// Projekt für anderen Tenant erstellen
$this->db->exec("
INSERT INTO projects (id, tenant_id, name)
VALUES ('other-project', 'other-tenant', 'Secret Project')
");
// Sollte nicht gefunden werden
$result = $this->repo->findById('other-project');
$this->assertNull($result);
}
public function testAutomaticTenantIdOnCreate(): void
{
$id = $this->repo->create(['name' => 'My Project']);
$stmt = $this->db->prepare(
"SELECT tenant_id FROM projects WHERE id = ?"
);
$stmt->execute([$id]);
$this->assertEquals('test-tenant-123', $stmt->fetchColumn());
}
}
Entscheidungshilfe
┌─────────────────────────────────────────────────────────────────┐
│ WELCHE STRATEGIE FÜR MEIN PROJEKT? │
├─────────────────────────────────────────────────────────────────┤
│ │
│ Anzahl Tenants? │
│ ├── <100 → Shared DB + Tenant-ID (einfachster Start) │
│ └── >100 → Weiter prüfen ↓ │
│ │
│ Compliance-Anforderungen? │
│ ├── Streng (DSGVO Art. 32, ISO 27001) │
│ │ └── Separate Schemas oder DB-per-Tenant │
│ └── Normal → Shared DB reicht │
│ │
│ Enterprise-Kunden mit eigenen Anforderungen? │
│ ├── JA → Hybrid (Shared + dediziert für Enterprise) │
│ └── NEIN → Eine Strategie für alle │
│ │
│ Tenant-Daten stark unterschiedlich groß? │
│ ├── JA → DB-per-Tenant (Backup/Restore einfacher) │
│ └── NEIN → Shared DB performanter │
│ │
└─────────────────────────────────────────────────────────────────┘
Fazit
Multi-Tenancy ist kein Hexenwerk, aber die Details entscheiden über Erfolg oder Datenleck:
- Shared DB + Tenant-ID: Für die meisten SaaS-Startups der richtige Start
- Tenant-Context als unveränderliches Request-Objekt: Die wichtigste Architektur-Entscheidung
- Row-Level Security: Defense in Depth auf Datenbankebene
- Jeder Query muss tenant_id prüfen: Keine Ausnahmen, auch nicht bei ID-Lookups
- Hybrid-Ansätze: In der Praxis oft am sinnvollsten
Mein Stack für neue Projekte: Shared DB + Tenant-ID + PostgreSQL RLS. Einfach genug für den Start, sicher genug für Produktion, migrierbar zu Database-per-Tenant wenn ein Enterprise-Kunde es braucht.
Weiterführende Ressourcen
Über Carola Schulte
Software-Architektin mit 25+ Jahren Erfahrung. Spezialisiert auf robuste Business-Apps mit PHP/PostgreSQL, Security-by-Design und DSGVO-konforme Systeme. 1,8M+ Lines of Code in Produktion.
Multi-Tenant-System geplant?
Lassen Sie uns besprechen, welche Architektur für Ihr SaaS-Projekt die richtige ist – kostenlos und unverbindlich.
Kostenloses Erstgespräch