Laravel Multi-Tenancy: Architektur, Pakete und Kosten im Überblick
Kurz gesagt
Eine mandantenfähige Laravel-Anwendung bedient mehrere Mandanten mit einer Codebase, und die Datengrenze lässt sich auf drei Wegen ziehen: gemeinsame Datenbank mit Tenant-Spalte als meist richtiger Start, eine Datenbank pro Mandant für harte Isolation oder ein Schema pro Mandant als Mittelweg. Umsetzen lässt sich das mit stancl/tenancy, spatie/laravel-multitenancy oder bei wenigen, bekannten Mandanten ohne Package. Kritisch sind Scopes, Queues, Caches, Dateien und Tests, nicht die Mandanten-Erkennung.
Sobald eine Webanwendung mehr als einen Kunden bedienen soll, steht die Frage im Raum: eine Installation pro Kunde oder eine Anwendung für alle? Die zweite Variante heißt Multi-Tenancy, auf Deutsch Mehrmandantenfähigkeit. Sie ist das Fundament fast jedes SaaS-Produkts und vieler Kundenportale. Wer in Laravel mehrere Mandanten in einer Anwendung betreiben will, baut eine mandantenfähige Laravel-Anwendung.
Laravel kann das gut. Die eigentliche Frage ist nicht ob, sondern wie, denn unter dem einen Begriff stecken sehr unterschiedliche Architekturen mit sehr unterschiedlichen Folgekosten. Dieser Artikel sortiert die Optionen, benennt die Werkzeuge, zeigt die Stellen, an denen Mandantentrennung in der Praxis bricht, und sagt ehrlich, wann die aufwendigste Lösung die falsche ist.
Was Mandantenfähigkeit bedeutet
Ein Mandant (Tenant) ist ein Kunde deiner Anwendung mit eigenem, abgeschottetem Datenraum: eigene Benutzer, eigene Inhalte, oft eigene Subdomain (kunde-a.deinprodukt.de) und eigenes Branding. Die Anwendung selbst existiert nur einmal. Ein Deployment, eine Codebase, ein Release für alle.
Der Gegenentwurf wäre eine Installation pro Kunde. Das klingt nach sauberer Trennung, skaliert aber nicht: Jedes Update muss n-mal ausgerollt werden, jeder Bugfix n-mal, jede Server-Konfiguration lebt n-mal. Ab einer Handvoll Kunden gewinnt fast immer die mandantenfähige Architektur, wenn sie sauber gebaut ist.
Die drei Architekturen für mehrere Mandanten
Mehrmandantenfähigkeit entscheidet sich an einer Frage: Wo verläuft die Datengrenze? Drei Antworten haben sich etabliert:
1. Gemeinsame Datenbank, Tenant-Spalte. Alle Mandanten teilen sich Tabellen; jede Zeile trägt eine tenant_id, und die Anwendung filtert jede Abfrage darauf. In Laravel geschieht das typischerweise über globale Eloquent-Scopes. Das ist die einfachste und günstigste Variante: eine Datenbank, ein Backup, eine Migration. Ihre Schwäche ist zugleich ihr Risiko: Die Trennung existiert nur in der Anwendungslogik. Ein vergessener Scope, eine handgeschriebene Query ohne Filter, und Mandant A sieht Daten von Mandant B.
2. Eine Datenbank pro Mandant. Jeder Mandant bekommt seine eigene Datenbank; die Anwendung wechselt je nach Request die Verbindung. Die Isolation ist strukturell statt logisch: Ein vergessener Filter kann keine fremden Daten mehr treffen, weil sie physisch woanders liegen. Auch Export, Restore oder die Löschung eines einzelnen Mandanten werden einfach. Der Preis: Migrationen, Backups und Monitoring multiplizieren sich mit der Mandantenzahl, und das Onboarding eines neuen Kunden heißt „Datenbank provisionieren”, nicht „Zeile anlegen”.
3. Ein Schema pro Mandant. Der Mittelweg, vor allem mit PostgreSQL: eine Datenbank, aber getrennte Schemata je Mandant. Härtere Trennung als die Tenant-Spalte, weniger Betriebsaufwand als getrennte Datenbanken, dafür an das Datenbanksystem gebunden und operativ anspruchsvoller als Variante 1.
Die Faustregel aus unserer Projektpraxis: Variante 1 ist der richtige Start, solange nichts Härteres verlangt wird. Harte Isolationsanforderungen kommen selten aus der Technik. Sie kommen aus Verträgen, Branchenregeln oder von Kunden, die im Audit die Frage stellen: „Liegen unsere Daten in derselben Tabelle wie die des Wettbewerbers?”
Wie erkennt Laravel den Mandanten?
Bevor irgendein Scope greifen kann, muss die Anwendung wissen, für welchen Mandanten sie gerade arbeitet. Das nennt man Mandanten-Erkennung (Tenant Identification), und sie läuft in der Regel früh in einer Middleware. Vier Wege sind üblich.
Subdomain
kunde-a.deinprodukt.de: Der erste Hostnamen-Teil identifiziert den Mandanten. Das ist der Klassiker für SaaS, weil Kunden eine „eigene” Adresse bekommen und du nur ein Wildcard-Zertifikat und einen Wildcard-DNS-Eintrag brauchst. Achte darauf, reservierte Namen (www, app, admin, api) zu sperren, und prüfe bei der Registrierung, ob die Subdomain gültig und noch frei ist.
Eigene Domain
portal.kunde-a.de: Größere Kunden wollen ihre eigene Domain. Dann liegt in deiner Datenbank eine Zuordnung von Domain zu Mandant, und jede Domain braucht ein eigenes TLS-Zertifikat. Das ist im Betrieb aufwendiger als eine Wildcard-Subdomain, aber für Whitelabel-Produkte oft Pflicht. Auch Rentlytics arbeitet domainbasiert: Jedes Modul registriert seine Domains selbst über Route::domain(...). Dort ist das aber keine dynamische Mandanten-Erkennung, sondern eine feste Zuordnung im Code, die wir im Artikel zum Modul-Muster im Detail zeigen.
Pfad
deinprodukt.de/kunde-a/rechnungen: Der Mandant steckt im URL-Pfad. Das braucht weder Wildcard-DNS noch zusätzliche Zertifikate und eignet sich für interne Tools oder Prototypen. Dafür müssen alle Routen unter einem Mandanten-Präfix hängen, und jeder generierte Link muss den Mandanten kennen, sonst läuft er ins Leere.
Header oder Token
Bei APIs und mobilen Apps gibt es oft keine Domain pro Mandant. Dann steckt die Kennung im Header oder im Token, zum Beispiel als Claim im Zugriffstoken. Der wichtige Punkt: Verlass dich nie auf einen frei setzbaren Header, ohne zu prüfen, dass der authentifizierte Benutzer überhaupt zu diesem Mandanten gehört. Ein Header, den der Client beliebig schickt, ist eine Behauptung und kein Beleg.
Unabhängig vom Weg gilt: Die Erkennung ist eine Sicherheitsgrenze. Prüfe, dass der angemeldete Benutzer zum erkannten Mandanten gehört, und lass die Anfrage mit 404 oder 403 scheitern, wenn nicht. Laut Dokumentation bringt stancl/tenancy für alle vier Wege fertige Middleware mit (Domain, Subdomain, Domain oder Subdomain, Pfad und Request-Daten, dort standardmäßig der Header X-Tenant oder der Query-Parameter tenant). Das Spatie-Paket liefert einen DomainTenantFinder mit und lässt dich eigene Finder schreiben, die von Spatie\Multitenancy\TenantFinder\TenantFinder erben.
Die Werkzeuge: stancl/tenancy, Spatie oder gar kein Package
Zwei Packages haben sich im Laravel-Ökosystem etabliert, mit gegensätzlicher Philosophie.
stancl/tenancy („Tenancy for Laravel”) will Mandantenfähigkeit weitgehend automatisch lösen. Laut Dokumentation gibt es einen automatischen Modus, in dem Datenbankverbindung, Cache, Dateisystem und Queues in den Mandantenkontext wechseln (über sogenannte Bootstrapper), und einen manuellen Modus, in dem du die Daten selbst scopest. Unterstützt sind Single-Database- und Multi-Database-Tenancy. Dazu kommen Konsolenbefehle wie tenants:migrate, tenants:seed und tenants:run und ein Event-System für den Lebenszyklus eines Mandanten. Das ist mächtig, gerade für Variante 2 mit getrennten Datenbanken. Es heißt aber auch, dass das Package tief in die Anwendung eingreift. Du solltest verstehen, was es tut, bevor du dich darauf verlässt.
spatie/laravel-multitenancy geht den umgekehrten Weg. Die Dokumentation beschreibt es als „Unopinionated multitenancy package” mit dem Anspruch, nur das Nötigste bereitzustellen: den aktuellen Mandanten bestimmen, beim Wechsel Tasks ausführen (etwa die Datenbankverbindung umschalten oder Cache-Präfixe setzen), Queue-Jobs mandantenbewusst machen und mit tenants:artisan Befehle für alle Mandanten ausführen. Auch hier sind eine gemeinsame Datenbank und mehrere Datenbanken möglich. Weniger Magie, mehr Eigenverantwortung.
stancl/tenancy und Spatie im Vergleich
| stancl/tenancy | spatie/laravel-multitenancy | |
|---|---|---|
| Ansatz | Viel Automatik über Bootstrapper | Schlanker Kern, du entscheidest |
| Mandanten-Erkennung | Middleware für Domain, Subdomain, Pfad, Request-Daten | Tenant Finder, DomainTenantFinder ist enthalten, eigene möglich |
| Datenbank-Modelle | Single- und Multi-Database | Single- und Multi-Database |
| Umschalten des Kontexts | Bootstrapper für Datenbank, Cache, Dateisystem, Queue, Redis | Tasks, die du konfigurierst oder selbst schreibst |
| Queues | Queue-Bootstrapper hängt die Mandanten-ID an den Job | Jobs per Marker-Interface oder Konfiguration mandantenbewusst |
| Konsole | tenants:migrate, tenants:seed, tenants:run und weitere |
tenants:artisan |
| Lebenszyklus | Events und Job-Pipelines für Anlegen, Migrieren, Löschen | Eigene Logik, die Mandanten-Verwaltung bleibt bei dir |
Keines von beiden ist „besser”. Wenn du viele Mandanten mit eigenen Datenbanken zur Laufzeit anlegen willst, spart dir stancl/tenancy viel Handarbeit. Wenn du die Kontrolle behalten willst oder deine Anforderungen von den Standardannahmen abweichen, ist Spatie die ehrlichere Basis. Prüfe in jedem Fall vor dem Einsatz, welche Laravel-Version und welche PHP-Version die aktuelle Paketversion unterstützt.
Und manchmal: keins von beiden. Ein Tenancy-Package lohnt sich, wenn Mandanten zur Laufzeit entstehen und einen echten Lebenszyklus haben: Self-Service-Registrierung, eigene Benutzerwelten, Abrechnung je Mandant. Sind die „Mandanten” dagegen wenige und im Code bekannt, ist ein Package oft die falsche erste Abstraktion. Wie das konkret aussieht, zeigt unser Rentlytics-Projekt: mehrere kommunale Mietspiegelrechner in einer Laravel-Codebase, getrennt über Modul-Service-Provider statt Tenancy-Package. Jedes Modul bringt eigene Routes, Views, Assets und Domains mit. Das ist die kleinste Architektur, die die echten Grenzen schützt.
Der Punkt, an dem Mehrmandantenfähigkeit scheitert: Isolation
Das größte Risiko einer mandantenfähigen Anwendung ist nicht Performance und nicht Skalierung. Es ist der Datenleck-Bug: Mandant A sieht Daten von Mandant B. Genau ein solcher Fehler kann das Vertrauen kosten, von dem ein SaaS-Produkt lebt. Deshalb gehört die Isolation nicht in die Kategorie „machen wir schon richtig”, sondern in die Architektur und die Tests. Die folgenden Abschnitte gehen die Stellen durch, an denen sie in der Praxis bricht.
Globale Scopes und ihre Fallstricke
Bei der gemeinsamen Datenbank ist der globale Eloquent-Scope das zentrale Werkzeug. Laut Laravel-Dokumentation implementierst du dazu das Interface Illuminate\Database\Eloquent\Scope, hängst den Scope per #[ScopedBy] oder addGlobalScope ans Modell, und er greift automatisch bei Eloquent-Abfragen dieses Modells. Das Gegenstück ist withoutGlobalScope() bzw. withoutGlobalScopes(), und genau hier liegen die Fallstricke:
- Nur Eloquent wird gescopt.
DB::table('invoices')und Roh-SQL kennen deinen Scope nicht. Auch stancl/tenancy weist in seiner Dokumentation darauf hin, dass direkte Datenbankabfragen nicht auf den aktuellen Mandanten begrenzt werden. withoutGlobalScopes()ist ein Generalschlüssel. Wer es ohne Argument aufruft, schaltet auch den Mandanten-Scope ab. Reports, Admin-Auswertungen und „schnelle Korrekturen” sind die typischen Orte dafür. Suche im Code regelmäßig danach, und schreibe die Ausnahme dann bewusst und lesbar.- Ohne Kontext darf der Scope nicht still durchlassen. Ein Scope, der bei fehlendem Mandanten einfach keinen Filter anhängt, macht aus einem vergessenen Kontext ein Datenleck. Besser ist es, geschlossen zu scheitern: kein Mandant, keine Zeilen oder eine Exception.
- Neue Datensätze brauchen den Mandanten. Beim Anlegen muss die
tenant_idzuverlässig gesetzt werden, am besten imcreating-Hook des Modells. - Unique-Regeln und Indizes. Eine Validierung wie
unique:posts,slugprüft über alle Mandanten hinweg. Du brauchst eine mandantenbezogene Regel und einen Unique-Index übertenant_idplus Spalte, sonst blockiert Mandant A den Slug von Mandant B.
Ein minimales Grundgerüst, das diese Punkte berücksichtigt:
<?php
namespace App\Tenancy;
final class CurrentTenant
{
private ?int $id = null;
public function id(): ?int
{
return $this->id;
}
public function set(?int $id): void
{
$this->id = $id;
}
}
<?php
namespace App\Models\Scopes;
use App\Tenancy\CurrentTenant;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Scope;
class TenantScope implements Scope
{
public function apply(Builder $builder, Model $model): void
{
$tenantId = app(CurrentTenant::class)->id();
// Ohne Mandantenkontext: keine Zeilen statt aller Zeilen.
if ($tenantId === null) {
$builder->whereRaw('1 = 0');
return;
}
$builder->where($model->qualifyColumn('tenant_id'), $tenantId);
}
}
<?php
namespace App\Models\Concerns;
use App\Models\Scopes\TenantScope;
use App\Tenancy\CurrentTenant;
trait BelongsToTenant
{
protected static function bootBelongsToTenant(): void
{
static::addGlobalScope(new TenantScope);
static::creating(function ($model) {
$model->tenant_id ??= app(CurrentTenant::class)->id();
});
}
}
Die CurrentTenant-Klasse registrierst du im Service Container mit $this->app->scoped(CurrentTenant::class); und befüllst sie in der Middleware zur Mandanten-Erkennung. Bewusst scoped und nicht singleton: Laut Laravel-Doku wird eine scoped-Bindung geleert, sobald ein Octane-Worker einen neuen Request oder ein Queue-Worker einen neuen Job beginnt. Ein Singleton dagegen lebt im Worker-Prozess weiter und nimmt den Mandanten des letzten Requests mit. Das ist bewusst die einfachste Form; die Packages bringen dafür eigene, ausgereiftere Mechanismen mit.
Queues, Jobs, Commands und Scheduler
Ein Job läuft nicht im Request, sondern in einem Worker-Prozess. Dort gibt es keine Subdomain und keine Session. Wenn der Job den Mandanten nicht kennt, hat dein Scope entweder keinen Kontext (und liefert nichts) oder, schlimmer, noch den Kontext des vorherigen Jobs, wenn du den Kontext als Singleton hältst, weil der Worker-Prozess lange lebt. Die scoped-Bindung von oben verhindert das Erbe, setzt den richtigen Mandanten aber nicht von selbst. Zwei Regeln helfen:
- Gib dem Job die Mandanten-ID mit (nicht nur ein Modell, dessen Mandant du hoffentlich richtig auflöst), und setze den Kontext am Anfang.
- Stelle den Kontext danach wieder her, auch bei Fehlern.
Laravel bietet dafür Job-Middleware:
<?php
namespace App\Jobs\Middleware;
use App\Tenancy\CurrentTenant;
use Closure;
class RunForTenant
{
public function handle(object $job, Closure $next): void
{
$current = app(CurrentTenant::class);
$previous = $current->id();
$current->set($job->tenantId);
try {
$next($job);
} finally {
$current->set($previous);
}
}
}
Im Job selbst gibst du die Middleware über die Methode middleware() zurück: return [new RunForTenant];. Die Packages nehmen dir diesen Teil ab: Bei stancl/tenancy hängt der Queue-Bootstrapper die Mandanten-ID laut Dokumentation an den Job-Payload und initialisiert den Mandanten beim Verarbeiten. Bei Spatie machst du Jobs entweder global über queues_are_tenant_aware_by_default mandantenbewusst oder einzeln über das Marker-Interface TenantAware. Zwei Details aus der Spatie-Doku, die man schnell übersieht: Closures können kein Interface implementieren, dort musst du die globale Einstellung nutzen oder selbst $tenant->execute() aufrufen, und ein mandantenbewusster Job, dessen Mandant inzwischen gelöscht wurde, scheitert mit CurrentTenantCouldNotBeDeterminedInTenantAwareJob.
Artisan-Commands und Scheduler haben ebenfalls keinen Request. Ein Command wie „Rechnungen versenden” muss entweder pro Mandant aufgerufen werden (stancl: tenants:run, Spatie: tenants:artisan) oder selbst über alle Mandanten iterieren und den Kontext pro Durchlauf setzen. Beim Scheduler ist das Muster, in routes/console.php einen schlanken Eintrag zu halten, der pro Mandant einen Job in die Queue stellt (Schedule::job(...) ist dafür in der Laravel-Doku vorgesehen). So läuft die eigentliche Arbeit mit sauberem Kontext in Workern, und ein langsamer Mandant blockiert die anderen nicht.
Caches, Dateien und Storage
Cache-Schlüssel wie dashboard.stats sind global. Zwei Mandanten teilen sich dann denselben Eintrag, und der Erste füllt den Cache für alle. Setze die Mandanten-ID in den Schlüssel oder ein Cache-Präfix. stancl/tenancy nutzt dafür laut Doku Cache-Tags und verlangt deshalb einen Cache-Store mit Tag-Unterstützung wie Redis; ein Redis-Bootstrapper ändert zusätzlich das Präfix pro Mandant und setzt phpredis voraus (Predis funktioniert dort nicht). Spatie bringt eine Task zum Präfixieren des Caches mit.
Dateien brauchen dieselbe Trennung wie Datenbankzeilen. Lege Uploads, generierte PDFs und Exporte unter einem Mandantenpfad ab und prüfe beim Ausliefern, dass der Pfad zum aktuellen Mandanten gehört. Ein erratbarer Dateipfad ist dasselbe Leck in anderem Gewand. Der Filesystem-Bootstrapper von stancl/tenancy passt Storage, storage_path() und asset() an, wobei Logs laut Dokumentation weiterhin in storage/logs landen. Eigene Disks musst du dort ausdrücklich konfigurieren.
Mails, Broadcasting und Suche
Drei Stellen, an denen Mandantenkontext gern verloren geht:
- Mails und Benachrichtigungen, die in die Queue gehen, werden im Worker gerendert. Branding, Absender und Links müssen dort aus dem Mandanten kommen. Wenn du den Kontext nicht in den Job mitgibst, geht die Mail mit dem Standard-Absender raus oder mit dem Logo des falschen Kunden.
- Broadcasting: Kanalnamen sollten die Mandanten-ID enthalten (zum Beispiel
tenant.{tenantId}.orders), und die Autorisierung des Kanals muss prüfen, dass der Benutzer zu diesem Mandanten gehört. Ein globaler Kanal wieordersschickt Ereignisse an alle. - Suche mit Laravel Scout: Laut Doku wird jedes Modell standardmäßig mit einem Index synchronisiert, der dem Tabellennamen entspricht. Bei einer gemeinsamen Datenbank landen die Datensätze aller Mandanten also im selben Index. Du musst entweder in jeder Suche nach dem Mandanten filtern oder über
searchableAs()einen Index pro Mandant bestimmen. Bei der Datenbank-Engine von Scout hatsearchableAs()keine Wirkung, dort bleibt dein normaler Eloquent-Scope maßgeblich.
Mandanten-Isolation testen
Eine gute Testsuite versucht aktiv, als Mandant A an Daten von Mandant B zu kommen. Solche Tests sind billig geschrieben und werden mit jedem Feature wertvoller. Ein Beispiel mit Pest für das Grundgerüst von oben. Es setzt voraus, dass es Factories für Tenant, User und Invoice gibt und dass deine Middleware den Mandantenkontext aus dem angemeldeten Benutzer setzt:
<?php
use App\Models\Invoice;
use App\Models\Tenant;
use App\Models\User;
use App\Tenancy\CurrentTenant;
beforeEach(function () {
$this->tenantA = Tenant::factory()->create();
$this->tenantB = Tenant::factory()->create();
// Daten von Mandant B anlegen, ohne dass ein Kontext gesetzt ist.
$this->invoiceB = Invoice::factory()->create([
'tenant_id' => $this->tenantB->id,
]);
$this->userA = User::factory()->create([
'tenant_id' => $this->tenantA->id,
]);
});
it('liefert fremde Datensätze nicht aus', function () {
$this->actingAs($this->userA)
->get(route('invoices.show', $this->invoiceB))
->assertNotFound();
});
it('listet fremde Datensätze nicht auf', function () {
$this->actingAs($this->userA)
->getJson(route('invoices.index'))
->assertOk()
->assertJsonMissing(['id' => $this->invoiceB->id]);
});
it('liefert ohne Mandantenkontext nichts', function () {
app(CurrentTenant::class)->set(null);
expect(Invoice::count())->toBe(0);
});
Der erste Test funktioniert, weil Laravel beim Route-Model-Binding eine normale Eloquent-Abfrage nutzt, auf die der globale Scope greift; der fremde Datensatz wird dann gar nicht erst gefunden. Ergänze Tests für alles, was Daten ausgibt: Exporte, Suche, Downloads, API-Endpunkte, Livewire-Komponenten. Ein weiterer Test gehört in jede Queue-Funktion: Job für Mandant A dispatchen, ausführen und prüfen, dass nur Daten von A betroffen sind und der Kontext danach zurückgesetzt ist.
Bei getrennten Datenbanken ändert sich die Testlage. Die stancl-Dokumentation rät im automatischen Multi-Database-Modus von :memory:-SQLite und vom RefreshDatabase-Trait ab, weil die Datenbankverbindung umgeschaltet wird, und warnt vor einem pauschalen Event::fake(), weil das Package stark auf Events beruht. Wer dort testet, legt den Mandanten in der Test-Vorbereitung an und initialisiert den Mandantenkontext explizit.
Migrationen bei einer Datenbank pro Mandant
Sobald jeder Mandant eine eigene Datenbank hat, gibt es zwei Arten von Migrationen: solche für die zentrale („Landlord”) Datenbank mit Mandanten, Domains und Plänen, und solche für die Mandanten-Datenbanken. Beide Packages trennen das. Bei Spatie liegen die Landlord-Migrationen in einem eigenen Ordner und werden mit php artisan migrate --path=database/migrations/landlord --database=landlord ausgeführt, die Mandanten-Migrationen laufen über php artisan tenants:artisan "migrate --database=tenant". Bei stancl/tenancy gibt es dafür tenants:migrate (alle Mandanten oder gezielt per --tenants), außerdem tenants:rollback und tenants:seed.
Worauf es in der Praxis ankommt:
- Deployment mit Migrationen für alle Mandanten. Migrationsläufe müssen Teil des Deployments sein, nicht ein manueller Schritt. Je mehr Mandanten, desto länger dauert der Lauf.
- Abwärtskompatible Migrationen. Während ein Deployment durch alle Datenbanken läuft, sind kurzzeitig Mandanten mit altem und mit neuem Schema gleichzeitig live. Spalten erst hinzufügen, später umbenennen oder entfernen, wenn kein Code mehr darauf zugreift.
- Teilfehlschläge einplanen. Wenn die Migration bei Mandant 37 von 120 scheitert, brauchst du einen klaren Weg: welcher Mandant hat welchen Stand, und wie läuft der Rest nach? Protokolliere pro Mandant.
- Neue Mandanten bekommen das aktuelle Schema. Beim Anlegen läuft die gesamte Migrationshistorie (oder ein Schema-Dump), und dann Seeds. Bei stancl/tenancy ist das als Job-Pipeline aus
CreateDatabase,MigrateDatabaseundSeedDatabaseabbildbar, standardmäßig synchron, auf Wunsch in der Queue.
Bei der gemeinsamen Datenbank entfällt all das, du hast eine Migration für alle. Das ist ein starkes Argument für Variante 1, solange nichts anderes verlangt wird.
Onboarding und Offboarding eines Mandanten
Ein Mandant hat einen Lebenszyklus, und jeder Schritt gehört in Code und Tests, nicht in eine Wiki-Anleitung.
Anlegen. Im Kern: Mandant-Datensatz erzeugen, Subdomain oder Domain zuordnen, Ressourcen bereitstellen (Datenbank oder Schema, Speicherpfad), Standarddaten anlegen, den ersten Administrator einladen. Mache das Anlegen idempotent und in einzelnen Schritten wiederholbar: Wenn das Bereitstellen der Datenbank klappt, die Willkommensmail aber nicht, willst du nur den letzten Schritt wiederholen. Bei Self-Service-Registrierung gehören Missbrauchsschutz, E-Mail-Bestätigung und die Reservierung von Subdomains dazu.
Export. Der Mandant verlangt seine Daten, sei es bei Vertragsende oder auf Anfrage. Bei einer Datenbank pro Mandant ist ein Dump relativ einfach. Bei der gemeinsamen Datenbank brauchst du einen Export, der alle Tabellen mit tenant_id abdeckt und die Dateien mitnimmt. Plane diesen Export früh. Er ist auch ein guter Test, ob dein Datenmodell sauber getrennt ist: Wenn du den Export nicht schreiben kannst, ohne ständig Sonderfälle zu finden, ist die Trennung zu weich.
Löschen. Offboarding heißt: Zugänge sperren, Daten löschen oder nach Frist löschen, Dateien und Suchindizes bereinigen, Domain freigeben. Entscheide vorher, ob erst gesperrt („Soft Delete mit Frist”) und später endgültig gelöscht wird. Auch Backups enthalten Daten gelöschter Mandanten. Kläre mit dem Kunden und deinem Datenschutzbeauftragten, wie lange das zulässig ist, und halte es im Vertrag fest. Eine Aussage zur rechtlichen Zulässigkeit im Einzelfall können wir hier nicht treffen. Bei stancl/tenancy gibt es dafür die Events TenantDeleted und DatabaseDeleted samt DeleteDatabase-Job.
Mandanten-spezifische Konfiguration und Branding
Fast jeder Mandant will etwas Eigenes: Logo, Farben, Absenderadresse, Sprache, Zeitzone, aktivierte Module. Halte das als Daten am Mandanten, nicht als Konfigurationsdateien im Code. Drei Muster haben sich bewährt:
- Einstellungen als strukturierte Spalte oder Tabelle, mit Standardwerten im Code und Überschreibungen pro Mandant.
- Branding-Werte als CSS-Variablen, die im Layout aus den Mandantendaten gesetzt werden, statt pro Kunde ein eigenes Stylesheet zu bauen. Das hält das Frontend-Bündel gleich und den Release-Prozess einfach.
- Funktionsschalter pro Mandant für Module oder Pläne, mit Prüfung im Backend, nicht nur durch ausgeblendete Menüpunkte.
stancl/tenancy bietet dafür ein optionales Feature TenantConfig: Über $storageToConfigMap ordnest du Mandanten-Attribute Laravel-Konfigurationsschlüsseln zu, die beim Initialisieren des Mandanten gesetzt werden. Bei Spatie schreibst du dafür eine eigene Task mit makeCurrent() und forgetCurrent(). Vorsicht bei Zugangsdaten, etwa einem eigenen SMTP-Passwort pro Mandant: Speichere sie verschlüsselt und logge sie nie.
Abrechnung pro Mandant
Die Abrechnung ist ein eigenes Arbeitspaket, kein Nebeneffekt der Mandantenfähigkeit, und sie wird hier nur konzeptionell beschrieben. Die wichtigsten Entscheidungen:
- Wer ist der Zahlende? Der Mandant als Organisation, nicht der einzelne Benutzer. Rechnungsadresse, Steuerangaben und Zahlungsmethode hängen am Mandanten.
- Was wird abgerechnet? Ein fester Plan, die Zahl der Benutzer, Nutzung (zum Beispiel Datensätze oder API-Aufrufe) oder eine Mischung. Nutzungsbasierte Abrechnung verlangt, dass du Nutzung pro Mandant sauber misst. Das ist wieder eine Frage der Isolation.
- Was passiert bei Nichtzahlung? Mahnstufen, Sperrung, Wiederfreischaltung. Das berührt Onboarding und Offboarding und braucht klare Zustände am Mandanten.
- Testphasen und Planwechsel erzeugen Sonderfälle, die du im Datenmodell vorsehen musst.
Wer den Zahlungsanbieter anbindet, sollte Zahlungsstatus als explizite Zustandsmaschine modellieren. Wie wir das in einem Laravel-Projekt gelöst haben, steht im Artikel zur VRPay-Zahlungsstatus-Zustandsmaschine.
Was Mehrmandantenfähigkeit kostet
Die ehrliche Antwort: Die Tenant-Logik selbst ist der kleinere Teil. Teuer wird das Drumherum, und zwar abhängig davon, wie viel „Produkt” dein Produkt sein soll:
Onboarding (von Hand oder per Self-Service mit automatischer Provisionierung), Abrechnung, laufender Betrieb bei getrennten Datenbanken und die Teststrategie sind die eigentlichen Kostentreiber. Dazu kommt die Nachrüst-Falle: Mandantenfähigkeit betrifft Datenmodell, Auth, Jobs und Speicher gleichzeitig. Sie nachträglich in eine gewachsene Single-Tenant-Anwendung einzubauen ist um ein Vielfaches teurer, als sie von Anfang an mitzudenken, selbst wenn der erste Release nur einen einzigen Mandanten hat.
Warum wir für solche Produkte fast immer bei Laravel landen, haben wir in Warum Laravel für Unternehmen? begründet. Das Ökosystem aus Auth, Queues, Policies und Testing ist genau das Fundament, das Mandantenfähigkeit braucht.
Wann du Multi-Tenancy nicht brauchst
Nicht jede Anwendung mit mehreren Kunden braucht Mehrmandantenfähigkeit. Prüfe vorher ehrlich:
- Du hast wenige Kunden, die alle dieselben Daten sehen. Dann reichen Rollen und Rechte in einer normalen Anwendung. Mandantenfähigkeit trennt Datenräume, Rechte regeln Zugriff innerhalb eines Datenraums.
- Die Varianten sind im Code bekannt. Wenige feste Ausprägungen mit eigener Route, eigenem Branding und eigenen Daten lassen sich als Module lösen, wie bei Rentlytics. Kein Self-Service, kein Mandanten-Lifecycle zur Laufzeit.
- Jeder Kunde braucht ein eigenes Release. Wenn Kunden unterschiedliche Versionen, eigene Anpassungen am Code oder getrennte Wartungsfenster verlangen, ist eine Installation pro Kunde ehrlicher, auch wenn sie mehr Betrieb kostet.
- Die Daten sind gar keine Kundendaten. Ein internes Tool für mehrere Abteilungen braucht meist Berechtigungen, keine Mandanten.
Das Gegenstück: Sobald Kunden sich selbst anmelden, unabhängig voneinander Benutzer verwalten und abgerechnet werden sollen, bist du bei echter Mandantenfähigkeit.
Entscheidungshilfe nach Situation
- SaaS-Produkt mit Self-Service, wachsende Kundenzahl → gemeinsame Datenbank mit Tenant-Spalte als Start, Tenancy-Package für den Lebenszyklus; getrennte Datenbanken erst, wenn Verträge oder Compliance es verlangen.
- Kundenportal für einen definierten Kundenkreis → meist Variante 1 mit sauberen Scopes; ein Package nur, wenn Mandanten wirklich einen Lebenszyklus haben.
- Branche mit harten Isolationsanforderungen (Gesundheits-, Finanzdaten, Behörden) → Datenbank oder Schema pro Mandant von Anfang an. Die strukturelle Trennung ist hier das Verkaufsargument, nicht der Kostenpunkt.
- Wenige, bekannte Varianten statt echter Mandanten → kein Tenancy-Package, sondern Modul-Struktur, siehe das Rentlytics-Muster.
Ehrliches Fazit
Mehrmandantenfähigkeit ist eine Architekturentscheidung, keine Package-Installation. Die Wahl zwischen gemeinsamer Datenbank, Schema- oder Datenbank-Trennung sollte aus deinen Isolationsanforderungen kommen, nicht aus der Feature-Liste eines Tools. Die Isolation selbst gehört in Scopes und Tests, nicht in gute Vorsätze, und sie muss auch dort halten, wo kein Request läuft: in Queues, im Scheduler, bei Caches, Dateien und der Suche. Wer das von Anfang an mitdenkt, bekommt mit Laravel eine Basis, die vom ersten Kunden bis zum echten SaaS trägt.
Du planst ein SaaS-Produkt oder Kundenportal? Wir bauen mandantenfähige Laravel-Anwendungen, von der Architekturentscheidung bis zum Betrieb. Ehrliche Einschätzung vorab, welche Variante dein Fall wirklich braucht: Erzähl uns von deinem Vorhaben. Mehr zu unserer Arbeitsweise auf der Seite unserer Laravel-Agentur.
Häufige Fragen
Was bedeutet Multi-Tenancy in Laravel?
Multi-Tenancy (Mehrmandantenfähigkeit) heißt: Eine Anwendung und eine Codebase bedienen mehrere Kunden, die Mandanten, deren Daten strikt voneinander getrennt bleiben. Jeder Mandant erlebt die Anwendung, als wäre sie nur für ihn da: eigene Benutzer, eigene Daten, oft eigene Subdomain und eigenes Branding. Typische Fälle sind SaaS-Produkte und Kundenportale.
Wie baue ich in Laravel eine Anwendung mit mehreren Mandanten?
Du brauchst vier Bausteine: eine Mandanten-Erkennung (Subdomain, Domain, Pfad oder Header), eine Datenstrategie (Tenant-Spalte, Schema oder eigene Datenbank pro Mandant), Isolation in der Anwendung (globale Scopes, mandantenbewusste Jobs, Cache- und Dateipfade) und Tests, die die Grenze angreifen. Ob du dafür stancl/tenancy, spatie/laravel-multitenancy oder eigenen Code nutzt, hängt davon ab, ob Mandanten zur Laufzeit entstehen.
Single Database oder eine Datenbank pro Mandant?
Eine gemeinsame Datenbank mit Tenant-Spalte ist einfacher zu betreiben und für die meisten Projekte der richtige Start. Die Trennung passiert in der Anwendung. Eine Datenbank pro Mandant isoliert härter und macht Export oder Löschung einzelner Mandanten einfach, kostet aber deutlich mehr Betriebsaufwand, weil sich Migrationen, Backups und Verbindungen mit jedem Mandanten multiplizieren. Harte Isolationsanforderungen aus Branche, Vertrag oder Behörde sind der häufigste Grund für den teureren Weg.
Welches Tenancy-Package für Laravel: stancl/tenancy oder Spatie?
Beide unterstützen laut Dokumentation eine gemeinsame und mehrere Datenbanken. stancl/tenancy automatisiert viel (Datenbank, Cache, Dateisystem, Queues, Mandanten-Erkennung per Middleware), greift dafür aber tief in die Anwendung ein. spatie/laravel-multitenancy ist bewusst schlanker und überlässt dir mehr Entscheidungen. Bei wenigen, im Code bekannten Mandanten ist oft gar kein Package die richtige Antwort.
Wie teste ich, dass Mandanten voneinander getrennt sind?
Schreibe Tests, die als Mandant A aktiv versuchen, Daten von Mandant B zu erreichen: über die ID in der URL, über Listen und Filter, über Exporte, Suche und API. Prüfe außerdem, dass ohne Mandantenkontext keine Daten geliefert werden und dass Queue-Jobs den richtigen Kontext setzen und danach zurücksetzen. Solche Tests sind billig und sichern jede spätere Änderung ab.
Wann brauche ich keine Multi-Tenancy?
Wenn deine Kunden dieselben Daten sehen, genügen Rollen und Rechte. Bei wenigen festen Varianten mit eigener Route und eigenem Branding reicht eine Modul-Struktur, und wenn jeder Kunde ein eigenes Release oder eigene Code-Anpassungen verlangt, ist eine Installation pro Kunde ehrlicher. Mandantenfähigkeit lohnt sich, wenn Kunden sich selbst anmelden, eigene Benutzer verwalten und einzeln abgerechnet werden.
Was kostet eine mandantenfähige Laravel-Anwendung?
Mandantenfähigkeit ist kein Feature, das man am Ende dazubucht, sondern eine Architekturentscheidung, die Datenmodell, Authentifizierung, Jobs, Backups und Onboarding betrifft. Nachträglich einbauen ist um ein Vielfaches teurer als von Anfang an mitdenken. Die Kostentreiber sind weniger die Tenant-Logik selbst als das Drumherum: automatisiertes Onboarding, Abrechnung je Mandant, Betrieb und die Teststrategie.
Passende Leistungen
Passende Projekte
Gründer & Full-Stack-Entwickler
20+ Jahre Erfahrung in der Webentwicklung. Spezialisiert auf Laravel, WordPress und individuelle Software für den Mittelstand.