Zum Inhalt springen
← Alle Artikel

Pennant in Laravel 13: Feature-Flags pro Tenant

12. Okt. 2026 · Backend · 6 min

·Von Dimitri Pisarev

Laravel Pennant beschreibt sich als Feature-Flag-Paket „without the cruft“. Bei meiner Multi-Tenant-Plattform trägt es mehr: die komplette Entitlement-Schicht. 51 Tenant-Capabilities und 23 Plattform-Flags laufen bei jeder Anfrage durch zwei Pennant-Stores. Das Paket hält. Gelernt habe ich am meisten aus drei Bugs und einer Migration.

Für wen gilt ein Feature-Flag in einer Multi-Tenant-App?#

Pennants Default-Scope ist der authentifizierte User. Für eine Single-Tenant-App ist das die richtige Antwort, für meine Plattform die falsche. Jeder Tenant bekommt seine eigene Datenbank, also fragt ein Flag nie, ob dieser User das Feature bekommt. Die Frage lautet, ob dieser Tenant die Capability gekauft hat. Eine Zeile in meinem Capability-Provider ersetzt den Scope-Resolver, und sie gibt ein kleines Value-Objekt zurück, das auf den nackten Tenant-Key serialisiert. Kein Model-Name in der Scope-Spalte, keine Morph-Map, nur die UUID. Verlangst du eine Capability außerhalb eines initialisierten Tenant-Kontexts, wirft der Resolver, statt still einen Default zu liefern.

Der Tenant-Katalog ist ein Enum mit 51 Fällen: Guest-Checkout, Theme-Presets, Referral-Programme, Support-Tickets, das Analytics-Modul. Jeder Fall trägt seinen eigenen Katalog-Default, und die Auflösungskette ist zwei Schritte lang. Eine explizite Grant-Zeile für diesen Tenant gewinnt, sonst gilt der Default. Pennants eigene capability:-Middleware sitzt vor den Routen und antwortet auf eine fehlende Capability mit einem 404, so behandelt die ganze App Oberflächen, für die ein Tenant nichts verloren hat.

Ein zweites Enum hält 23 plattformseitige Schalter: neues Tenant-Provisioning, Self-Registration, Custom Domains, die Analytics-Boards. Das sind meine Türen über den Tenants, sie leben deshalb in ihrem eigenen Store und mischen sich nie mit den Capability-Zeilen.

Zwei Stores und eine Verbindung, die Tenancy nicht erwischt#

Beide Stores liegen in einer Config-Datei, und das Interessante ist, was sie gemeinsam haben:

'database' => [
    'driver'     => 'database',  // wird von meinem eigenen Creator abgefangen
    'connection' => 'central',
    'table'      => 'tenant_capability_grants',
],
'central' => [
    'driver'     => 'Database',  // großes D mit Absicht
    'connection' => 'central',
    'table'      => 'platform_flags',
],

Jeder Store pinnt seine Verbindung beim Namen, und der Database-Driver des Vendors liest diesen Namen bei jeder Abfrage neu. Tenancy tauscht Laravels Default-Connection, sobald ein Tenant-Kontext startet. Genau deshalb bleiben die Grants durch diesen Pin in der zentralen Datenbank, während Tenant-Code läuft. Die Platzierung ist das Sicherheitsmodell: Manche Tenants halten Root-Credentials über ihre eigene Datenbank, und die Quelle der Berechtigungen darf niemals dort liegen, wo ein Tenant schreiben kann. Es gibt nichts zu fälschen und keinen Spiegel, der synchron bleiben müsste.

Die Schreibweise des Driver-Keys ist tragend, ein Satz, den ich ungern schreibe. Der kleine database-Key wird von meinem eigenen Creator abgefangen, der große Database-Key läuft in den eingebauten des Vendors. Vertust du dich, löst der Plattform-Store auf die Capability-Tabelle auf. Ein Kommentarblock über der Config erklärt das für alle, die nach mir kommen, denn niemand merkt sich so etwas.

Warum mein Treiber beim Lesen nie schreibt#

Stock-Pennant hat eine bequeme Angewohnheit: Der Database-Driver persistiert den berechneten Wert beim ersten Lesen. Als Rollout-Snapshot ist das freundlich. Als Entitlement-Store ist es ein Bug, den du nur noch nicht gefunden hast. Das erste Lesen eines Tenants ohne Grant würde den Katalog-Default in einer Zeile einfrieren, und ein späteres Umbiegen des Katalogs würde die Tenants nie erreichen, die nie einen expliziten Grant bekamen.

Darum tauscht der Creator eine Unterklasse ein, die den Schreibpfad entfernt. Zeilen entstehen nur durch explizite Schreibvorgänge über die eine Naht und verschwinden beim Löschen, und Lesevorgänge lösen jedes Mal aus dem Katalog auf. Ein Feature-Test liest, schreibt und liest erneut, um zu beweisen, dass das erste Lesen nichts gespeichert hat. Registrieren musst du die Unterklasse außerdem vor der ersten Feature-Interaktion des Boot-Zyklus, sonst löst der Manager einmal den Vendor-Driver auf, und der Tausch findet nie statt.

Der Cache, der zwischen Tenants blutet#

Pennant hält aufgelöste Werte in einem In-Memory-Decorator-Cache. Der Vendor leert ihn an Queue-Job-Grenzen und an Octane-Request-Grenzen. An einem Tenancy-Wechsel rührt er ihn nicht, und mein Scheduler läuft Tenants einzeln in einem Prozess ab. Der Fehler ist still: Eine Schleife, die Tenant für Tenant initialisiert, kann einen unter Tenant A aufgelösten Wert an Tenant B ausliefern, und nichts wirft. Die Familie ist dieselbe wie bei den Scheduler-Überraschungen aus dem Datenbank-pro-Tenant-Setup, und die Behandlung auch: Ein Listener auf dem Tenancy-Initialized-Event leert den Flag-Cache und mein Tenant-Memo, und jeder Schreibvorgang über die Naht leert beides erneut. Langlebigen Workern muss auch das Memo an Job-Grenzen weggenommen werden, sonst erbt der nächste Job den aufgelösten Katalog des vorherigen.

Der TTL-Bug, der gesperrte Routen offen hielt#

Grants können ablaufen. Ein Tenant bekommt eine Capability für ein begrenztes Fenster, und der gespeicherte Wert ist kein Bool, sondern ein JSON-Objekt mit einer Frist. Die Falle: Pennant zählt jeden Wert außer false als aktiv. Ein abgelaufener Grant ist in PHP ein Array, und ein Array ist nicht false, also blieb er für immer aktiv. Die Middleware vergleicht streng gegen false, und die gesperrten Routen lieferten weiter, lange nach der Frist. Meine eigene Naht normalisierte den Wert auf ihren Lesevorgängen korrekt, was den Bug schwerer sichtbar machte: Zwei Codepfade, zwei Deutungen derselben Zeile.

Der Fix, am 7. Oktober ausgerollt, verlagert die Normalisierung in den Treiber. Eine gespeicherte Frist wird gegen die Uhr bewertet, plain Bools laufen durch, und alles andere bleibt aktiv, so wie es die Vendor-Semantik vorsieht. Eine Naht, eine Interpretation, und ein Test, der für jeden Pfad festhält, dass eine abgelaufene Frist als aus gelesen wird.

Was ich einem Team zu diesem Bau mitgeben würde#

Drei Gewohnheiten tragen das Ganze. Erstens ein Single Writer: Ein Architektur-Test läuft über app/ und lässt den Build fallen, sobald die Feature-Fassade außerhalb von vier sanktionierten Dateien auftaucht, also geht jedes Lesen und Schreiben durch eine Naht, über die ich noch nachdenken kann. Zweitens zwei Schichten: Eine Plattform-Haupttür und die Tenant-Capability müssen beide zustimmen, deshalb ist ein Soft Launch überhaupt möglich. Drittens Ehrlichkeit über die Kosten: Jede Prüfung pro Request ist eine Abfrage gegen die zentrale Datenbank, das Query-Budget merkt es, deshalb löst der Katalog einmal pro Tenant und Prozess auf und bleibt memoisiert, bis jemand schreibt.

Trials laufen über dieselbe Naht. Ein Tenant-Admin schaltet eine Capability für ein begrenztes Fenster frei, einmal im Leben des Tenants, und Marker und Grant committen in einer Transaktion, damit ein Wiederholungsversuch die Freischaltung nicht doppelt ausgibt.

Keine davon brauchte einen Flag-Plattform-Vendor. Pennant ist klein genug, um es an einem Nachmittag zu lesen, und unter Laravel 13, das im März erschienen ist, verhält es sich wie unter seinen Vorgängern, und das ist das Kompliment, das ich wirklich meine. Bei Werkzeugfragen entscheide ich wie bei Produktfeatures über Gates: Ein Verbot von AI-Coding-Tools scheitert, wo ein Gate hält, das Argument stammt aus einer Snyk-Security-Session.