Database-per-Tenant auf Laravel 12: Wie Zenpresa jeden Betrieb isoliert und Module als Composer-Pakete ausliefert
Die erste Architekturentscheidung bei Zenpresa war die, die wir verworfen haben. Der naheliegende Weg, ein Multi-Tenant-SaaS auf Laravel zu bauen, ist eine tenant_id-Spalte auf jeder Tabelle, ein Global Scope, der jeder Abfrage ein WHERE tenant_id = ? anhängt, und die Hoffnung, dass niemand je eine Abfrage schreibt, die den Scope umgeht. Die meisten Plattformen sind so gebaut. Die meisten von ihnen haben irgendwann einem Kunden die Daten eines anderen Kunden gezeigt.
Wir haben uns stattdessen entschieden, jedem Betrieb eine eigene Datenbank zu geben. In diesem Beitrag geht es darum, warum, was das kostet, wie das Modulsystem darauf aufsetzt — und um die eine Bug-Klasse, die selbst dann überlebt, wenn Isolation richtig gemacht ist.
Warum eine Datenbank pro Tenant
Drei Gründe, in der Reihenfolge, wie oft sie zur Sprache kommen.
Blast Radius. Ein Bug in einem Shared-Schema-System ist per Default ein tenant-übergreifender Bug. Ein vergessener Scope, eine Raw Query, ein Join, der die falsche Seite zieht, ein Admin-Tool mit Wildcard: Jeder davon lässt Daten zwischen Kunden durchsickern. In einem Database-per-Tenant-System liefern dieselben Bugs die falschen Zeilen innerhalb eines Betriebs. Schlimm, aber behebbar — und nie eine Meldung wegen Datenpanne.
Backup und Restore pro Kunde. Wenn ein Betrieb anruft und sagt „wir haben aus Versehen ein Jahr Rechnungen gelöscht”, ist die Antwort im Shared Schema ein vorsichtiger, chirurgischer Restore aus dem Snapshot einer Datenbank, die auch jeden anderen Kunden enthält. Mit einer Datenbank pro Betrieb ist die Antwort: eine Datenbank wiederherstellen. Der Export beim Weggang ist dieselbe Geschichte: ein Dump einer Datenbank, kein gefilterter Auszug mit juristischer Prüfung.
Keine Bug-Klasse „vergessener Scope”. Das ist der Grund, der uns am wichtigsten ist. In einem Shared Schema ist Tenant-Isolation eine Eigenschaft, die man in jeder Abfrage neu herstellen muss — für die gesamte Lebensdauer der Codebasis. In einem Database-per-Tenant-System ist sie eine Eigenschaft der Verbindung. Sobald der Request an einen Tenant gebunden ist, sind jedes Eloquent-Model, jedes Raw Statement und jeder Queued Job, der diesen Kontext erbt, per Konstruktion isoliert. Neue Engineers können es nicht falsch machen, weil es nichts gibt, das man richtig machen müsste.
Wir verwenden das Paket stancl/tenancy auf Laravel 12 mit Postgres, und es übernimmt die Schwerarbeit: eine Datenbank bei der Registrierung anlegen, die Verbindung pro Request umschalten und Migrationen über alle Tenants hinweg ausführen.
Zentrale Datenbank vs. Tenant-Datenbanken
Es gibt zwei Arten von Datenbanken im System.
Die zentrale Datenbank hält, was oberhalb eines einzelnen Betriebs existiert: das Tenant-Register, die Domains, die auf jeden Tenant zeigen, Abonnement- und Abrechnungsstatus für die Plattform selbst, Benutzer und Rollen auf Plattformebene, den Modulkatalog und den Audit-Trail, wer was provisioniert hat. Wenn eine Tabelle die Beziehung zwischen einem Betrieb und der Plattform beschreibt, lebt sie hier.
Jede Tenant-Datenbank hält alles, was dem Betrieb gehört: Kunden, Termine, Leistungen, Mitarbeiter, Rechnungen, Zahlungen, Website-Seiten, Branding, E-Mail-Vorlagen, den Gesprächsverlauf des Assistenten und so weiter. Wenn eine Tabelle beim Weggang des Betriebs exportiert würde, lebt sie hier.
Der Migrationssatz ist auf dieselbe Weise geteilt. Zentrale Migrationen laufen einmal. Tenant-Migrationen laufen einmal pro Tenant-Datenbank, und ein Deploy, das den Rechnungen eine Spalte hinzufügt, führt diese Migration nacheinander über jeden Betrieb aus. Die Trennung wird per Verzeichnis erzwungen, damit eine Migration nicht versehentlich auf der falschen Seite landet.
Den Tenant pro Request auflösen
Jeder Tenant hat mindestens eine Domain: standardmäßig eine Subdomain auf unserer Plattform und eine eigene Domain, sobald er eine verbindet. Die Request-Pipeline schlägt den Host nach, findet den Tenant und schaltet die Standard-Datenbankverbindung um, bevor irgendein Anwendungscode läuft. Controller, Models und Services erwähnen den Tenant nie. Sie fragen „die Datenbank” ab, und die Datenbank ist die richtige.
Der subtile Teil ist alles, was nicht innerhalb eines Requests läuft. Queued Jobs, geplante Commands, Event Listener, die nach der Response feuern, und die Tool-Schleife des KI-Assistenten müssen alle wissen, zu welchem Tenant sie gehören. Das Tenancy-Paket serialisiert die Tenant-Kennung auf jeden Queued Job und initialisiert die Verbindung neu, wenn der Job läuft — ein Job „Rechnungserinnerung senden”, der im Request von Tenant A dispatcht wurde, läuft eine Stunde später gegen die Datenbank von Tenant A. Für geplante Arbeit, die mehrere Tenants umfasst, iteriert ein Command über das Register und initialisiert jeden Tenant der Reihe nach.
Die Regel, die wir im Code Review durchsetzen, ist einfach: Wenn es Tenant-Daten berührt und nicht in einem Request steckt, muss es in einem Tenant-Kontext laufen, und der Test muss das beweisen.
Module als eigenständige Composer-Pakete
Zenpresa verkauft Features à la carte, also ist die Codebasis genauso organisiert. Jedes Modul — Terminplanung, Rechnungsstellung, Zahlungen, Kunden-Hub, Marketing, Website-Builder, KI-Assistent und einige kleinere — ist ein eigenständiges Composer-Paket in einem modules/-Verzeichnis, eingebunden über ein Path Repository. Jedes hat seine eigene composer.json, seinen eigenen Namespace und seinen eigenen Service Provider, den Laravel automatisch erkennt.
Die Anwendung selbst ist weiterhin ein einziges Deployable: ein Laravel-Prozess, ein Container-Image, ein Deploy. Das ist ein modularer Monolith, keine Microservices. Wir bekommen die organisatorischen Vorteile von Modulen (klare Verantwortlichkeit, klare Grenzen, die Möglichkeit, ein Modul später herauszulösen) ohne die Betriebskosten verteilter Systeme.
Praktische Vorteile, die wir nicht erwartet hatten:
- PHPStan und die Test-Suite laufen pro Modul. Eine Änderung an der Rechnungsstellung lässt zuerst die Rechnungs-Tests laufen. Modulübergreifende Tests gibt es, aber sie sind eine separate, langsamere Stufe.
- Die Abhängigkeitsrichtung ist sichtbar. Die
composer.jsoneines Moduls deklariert, wovon es abhängt. Wenn Marketing Kunden braucht, ist das eine deklarierte Abhängigkeit. Würden Kunden plötzlich Marketing brauchen, würde der Zyklus im Manifest auftauchen — nicht sechs Monate später in einer Debugging-Session. - Extraktion ist eine echte Option. Wir haben noch kein Modul in einen separaten Service extrahiert, und vielleicht müssen wir das nie. Aber die Grenzen sind bereits gezogen, und das ist mehr wert als die Extraktion selbst.
Was ein Modul besitzt
Ein Modul besitzt alles rund um sein Feature:
- Routen für seine API-Endpunkte, registriert vom Service Provider unter dem Präfix des Moduls.
- Tenant-Migrationen für seine Tabellen, entdeckt aus dem eigenen Migrationsverzeichnis des Moduls.
- Models, Services und Jobs im Namespace des Moduls.
- Frontend-Seiten im entsprechenden React-Verzeichnis, lazy geladen, damit ein Tenant ohne das Modul dessen Code nie herunterlädt.
- KI-Tools, die der Assistent aufrufen kann, jeweils mit dem Modul-Key getaggt, sodass sie nur erscheinen, wenn das Modul aktiviert ist.
- Ein Feature-Key, der alles zusammenhält.
Ein Modul für einen Tenant zu aktivieren ist ein Abonnement-Flag. Eine Middleware prüft das Flag bei jedem Request an eine Modul-Route und liefert eine klare „Modul nicht aktiviert”-Antwort statt eines 404. Das Frontend liest dieselben Flags, um zu entscheiden, welche Navigationspunkte und Seiten es zeigt. Die KI-Tool-Registry liest sie, um zu entscheiden, welche Tools das Modell sehen darf. Ein Flag, drei Durchsetzungspunkte, alle lesen aus derselben Source of Truth.
Trade-offs, die wir akzeptiert haben
Database-per-Tenant ist nicht umsonst, und es wäre unehrlich, es so darzustellen.
N-Datenbank-Migrationen. Ein Deploy, das eine Tenant-Tabelle ändert, führt die Migration gegen jeden Tenant aus. Bei einer überschaubaren Zahl von Tenants dauert das Sekunden. Bei vielen Tausend dauert es echte Zeit und muss gegen einen Ausfall auf halbem Weg robust sein. Wir führen Tenant-Migrationen als queued, wiederaufnehmbaren Job aus, der den Fortschritt pro Tenant festhält, und wir entwerfen Migrationen additiv, damit eine teilweise migrierte Flotte weiterhin funktionsfähig ist.
Tenant-übergreifendes Reporting ist schwieriger. „Wie viele Termine wurden letzten Monat plattformweit gebucht?” ist im Shared Schema ein Einzeiler und bei uns ein Fan-out. Wir schieben Aggregate auf Plattformebene per Zeitplan in die zentrale Datenbank, statt Tenants live abzufragen. Der Tausch lohnt sich: Reporting ist ein geplantes Anliegen, Isolation ein Anliegen bei jedem Request.
Verbindungsmanagement. Postgres-Verbindungen sind nicht umsonst, und ein einzelner Prozess, der innerhalb einer Worker-Lebensdauer viele Tenants bedient, muss Verbindungen sorgfältig wiederverwenden. Das Tenancy-Paket übernimmt das Umschalten; wir mussten Pool-Größen tunen und sicherstellen, dass langlebige Worker nicht pro berührtem Tenant eine Verbindung leaken.
Lokale Entwicklung ist schwerer. Eine frische Dev-Umgebung muss mindestens eine Tenant-Datenbank provisionieren, bevor irgendetwas funktioniert. Wir haben das in einen einzigen Command verpackt, aber es ist immer noch mehr als migrate --seed.
Die Bug-Klasse, die überlebt
Isolation per Verbindung beseitigt den Vergessener-Scope-Bug aus Tenant-Tabellen. Sie beseitigt ihn nicht aus zentralen Tabellen, die tenant-gebunden sind.
Manche Daten sind legitimerweise zentral, gehören aber zu einem bestimmten Tenant: die Domains des Tenants, sein Abonnement, seine API-Keys auf Plattformebene, das Audit-Log seiner Provisionierung. Diese Zeilen liegen in der zentralen Datenbank mit einer Tenant-Kennungsspalte, und jede Abfrage darauf aus einem Tenant-Kontext heraus braucht weiterhin das Prädikat. Das Tenancy-Paket kann hier nicht helfen, weil die zentrale Verbindung per Design nicht tenant-gebunden ist.
Das ist genau die Form von Bug, die ein Single-Tenant-Test-Fixture nicht fangen kann. Ein Test, der einen Tenant provisioniert, die zentrale Domains-Tabelle abfragt und prüft, dass das Ergebnis die Domain des Tenants enthält, besteht mit oder ohne Prädikat, weil nur die Daten eines Tenants zurückgegeben werden können. Der Test muss zwei Tenants provisionieren und prüfen, dass die Zeilen des zweiten nicht erscheinen. Wir haben jetzt einen Basis-Testfall, der genau das für jede zentrale Tabelle mit Tenant-Spalte tut, und einen Punkt auf der Review-Checkliste, der fragt: „Sind diese zentralen Daten tenant-gebunden, und beweist der Test, dass der andere Tenant ausgeschlossen ist?”
Die Lektion gilt über unseren Stack hinaus: Was auch immer Ihre Isolationsgrenze ist — finden Sie die Daten, die außerhalb liegen, und testen Sie diese Daten mit zwei Tenants, nie mit einem.
Wo sich das später auszahlt
Zwei Entscheidungen, die direkt aus der Architektur folgen, haben sich mehrfach bezahlt gemacht.
Die erste ist der KI-Assistent. Weil die Verbindung tenant-gebunden ist, ist jedes Tool, das der Assistent aufruft, per Konstruktion isoliert. Das Tool filtert nicht nach Tenant; es kann die Datenbank eines anderen Tenants überhaupt nicht erreichen. Das erlaubte uns, die Sicherheitsarbeit am Assistenten auf die Probleme zu konzentrieren, die tatsächlich übrig bleiben — davon handeln die nächsten Beiträge dieser Serie.
Die zweite ist Kostenkontrolle. Metering der Assistenten-Nutzung pro Tenant, Ratenbegrenzung pro Tenant und Obergrenzen pro Tenant ergeben sich ganz natürlich, wenn der Tenant ein erstklassiges Laufzeitkonzept ist statt einer Spalte. Die Muster aus unserem früheren Beitrag zu LLM-API-Ratenbegrenzung und Kostenkontrolle fügen sich ohne Anpassung ein.
Wenn Sie eine Plattform bauen, auf der sich die Daten von Kunden nie berühren dürfen, und Sie sich den operativen Overhead leisten können, würden wir dieselbe Wahl wieder treffen. Wenn Sie eine Consumer-App mit Millionen winziger Accounts bauen, sollten Sie das vermutlich nicht. Die richtige Antwort hängt davon ab, was ein Leak kosten würde — und für die Betriebe, die Zenpresa bedient, lautet die Antwort: alles.
Weiter in der Serie: Ein agentischer Assistent in Laravel mit dem Anthropic PHP SDK: Tools, Model Lanes, Prompt Caching und Metering pro Tenant
FAQ: Database-per-Tenant auf Laravel
Ist Database-per-Tenant langsamer als ein Shared Schema?
Pro Request: nein. Sobald die Verbindung umgeschaltet ist, treffen Abfragen auf eine kleinere Datenbank mit kleinerem Working Set, was meist schneller ist als eine geteilte Tabelle mit Tenant-Prädikat auf jedem Index. Die Kosten liegen woanders: Migrationen laufen einmal pro Tenant, tenant-übergreifendes Reporting braucht einen Fan-out oder ein geplantes Aggregat, und Connection Pooling braucht Sorgfalt in langlebigen Workern.
Woher wissen Queued Jobs, zu welchem Tenant sie gehören?
Die Tenancy-Schicht serialisiert die Tenant-Kennung auf jeden Job, der aus einem Tenant-Kontext heraus dispatcht wird, und initialisiert den Tenant inklusive Datenbankverbindung neu, wenn der Job läuft. Die Regel, die wir durchsetzen: Jeder Code, der Tenant-Daten außerhalb eines Requests berührt, muss in einem expliziten Tenant-Kontext laufen, und sein Test muss das beweisen.
Was ist ein modularer Monolith, und warum keine Microservices?
Ein modularer Monolith ist eine einzige deploybare Anwendung, deren Code in strikt abgegrenzte Module organisiert ist — in unserem Fall eigenständige Composer-Pakete mit eigenen Routen, Migrationen, Models und Service Providern. Wir bekommen klare Verantwortlichkeit und Abhängigkeitsrichtung ohne den Overhead verteilter Systeme. Die Extraktion in separate Services bleibt möglich, weil die Grenzen bereits existieren.
Eliminiert Database-per-Tenant tenant-übergreifende Datenlecks?
Für Daten in Tenant-Datenbanken: ja, per Konstruktion. Für zentrale Tabellen mit Tenant-Kennung — Domains, Abonnements, Plattform-API-Keys — ist das Prädikat weiterhin nötig, und das Tenancy-Paket kann es nicht erzwingen. Testen Sie diese Tabellen mit zwei Tenants und prüfen Sie, dass die Zeilen des zweiten Tenants ausgeschlossen sind; ein Single-Tenant-Fixture besteht mit oder ohne Filter.
Wie geht man mit einer Migration um, die auf halbem Weg über Tausende Tenant-Datenbanken fehlschlägt?
Tenant-Migrationen laufen als queued, wiederaufnehmbarer Job, der den Fortschritt pro Tenant festhält, sodass ein Fehler beim fehlgeschlagenen Tenant wieder aufsetzt, statt von vorn zu beginnen. Migrationen sind additiv entworfen, damit eine teilweise migrierte Flotte Requests weiterhin korrekt bedient, während der Job aufholt.
Verwandte Artikel
Ein agentischer Assistent in Laravel mit dem Anthropic PHP SDK: Tools, Model Lanes, Prompt Caching und Metering pro Tenant
Zenpresas KI-Assistent mit dem Anthropic PHP SDK in Laravel: Tool-Vertrag, gefilterte Registry, Model Lanes, Prompt Caching, SSE-Streaming, Tenant-Metering.
Zenpresa stellt sich vor: Alles, womit Ihr Dienstleistungsbetrieb arbeitet, unter Ihrer eigenen Marke
Zenpresa ist eine modulare Plattform für Dienstleister: Terminplanung, Rechnungen, Zahlungen, Kunden-Hub und Website unter Ihrer Marke, plus KI-Assistent.
CI-Checks, die Linter nicht leisten können: API-Contract-Checks, Mobile-Pattern-Checks, Warning-Ratchets und Tests, die Stripe-Bugs festnageln
Vier CI-Checks, die Zenpresa nach echten Bugs ergänzt hat: API-Contract-Check über beide Seiten, Mobile-Pattern-Check für JSX, Warning-Ratchet, Stripe-Tests.