SaaS Feature Gating: Entitlements, Plan-Limits und die Architektur, die skaliert
Jede SaaS-Codebasis, die wir auditieren, enthält irgendeine Version dieser Zeile:
if ($user->getPlan() === 'pro') {
// show the feature
}
Am ersten Tag funktioniert das. Achtzehn Monate später existiert derselbe Check an vierzig Stellen, drei davon widersprechen sich, und das Marketing will ein Feature von Pro nach Business verschieben, ohne die zwölf Enterprise-Kunden zu brechen, die individuelle Konditionen ausgehandelt haben. Spätestens dann ist SaaS Feature Gating kein Conditional mehr, sondern ein Architekturproblem. Dieser Beitrag beschreibt die Architektur, die trägt: einen Entitlement-Service, ein Datenmodell für Plan-Limits, Enforcement-Patterns für ein Symfony-Backend und ein React-Frontend sowie die operative Arbeit, die niemand einplant, etwa Plan-Migrationen und Overrides pro Kunde.
Warum SaaS Feature Gating verrottet
Das Muster if plan == 'pro' koppelt zwei Fragen, die sich unterschiedlich schnell ändern: was ein Nutzer tun darf und wofür er bezahlt. Produktfähigkeiten ändern sich, wenn Engineering ausliefert. Preise ändern sich, wenn Sales und Marketing es beschließen. Leben beide im selben Conditional, wird jedes Pricing-Experiment zur Codeänderung, und jede Codeänderung riskiert das Billing.
Die Fehlermuster sind vorhersehbar. Ein Feature-Check gegen den Plannamen bricht in dem Moment, in dem du den Plan umbenennst oder aufteilst. Bestandskunden mit Grandfathering brauchen das alte Verhalten, also ergänzt jemand || $user->getPlan() === 'pro_legacy_2024', und das Conditional wuchert. Sales schließt einen Enterprise-Deal mit individuellem Seat-Limit ab, und weil es keinen Ort gibt, an dem „dieser Kunde bekommt 500 Seats im Business-Plan" gespeichert werden kann, landet der Workaround in einer Config-Datei oder, schlimmer, als hartkodierte Kunden-ID.
Nichts davon ist ein Kompetenzproblem. Es ist das, was passiert, wenn die Codebasis kein Konzept zwischen „Plan" und „Feature" kennt.
Der Entitlement-Service: Fähigkeiten vom Billing entkoppeln
Die Lösung ist eine Schicht dazwischen. Ein Plan ist ein kommerzielles Artefakt: ein Name, ein Preis, ein Abrechnungsintervall. Ein Entitlement ist eine Fähigkeit: api_access, sso, audit_log, seats: 25, projects: 100. Der Entitlement-Service besitzt genau ein Mapping: Plan zu Entitlements. Anwendungscode fragt nie „auf welchem Plan ist dieser Account?". Er fragt „hat dieser Account sso?" oder „wie viele Seats bekommt dieser Account?".
Diese eine Indirektion bringt dir viel:
- Das Pricing kann sich ändern, ohne Feature-Code anzufassen. Ein Feature zwischen Plänen zu verschieben ist eine Datenänderung.
- Grandfathering wird zu einer versionierten Plan-Zeile statt zu einem Conditional.
pro_2024undpro_2026mappen auf unterschiedliche Entitlement-Sets, und kein Anwendungscode weiß davon oder muss es wissen. - Enterprise-Overrides bekommen ein Zuhause. Eine Override-Tabelle auf Account-Ebene schlägt eine hartkodierte Kunden-ID in jeder Hinsicht, die während eines Incidents zählt.
Wo du Entitlements prüfst, ist genauso wichtig wie wo du sie speicherst. Prüfe an der API-Grenze, denn die API ist der echte Vertrag, und alles, was ein Client aufrufen kann, kann auch ein Skript aufrufen. Prüfe zusätzlich im UI-Rendering, aber behandle das als Präsentation, nicht als Sicherheit. Ein versteckter Button ist eine Höflichkeit; ein abgelehnter Request ist eine Garantie.
Das Datenmodell für Plan-Limits
Boolesche Entitlements sind die einfache Hälfte. Bei Limits verdient das Modell sein Geld, denn „wie viele" kommt in drei Varianten vor, die sich unterschiedlich verhalten:
Ressourcen-Quotas sind zählbare Dinge: Seats, Projekte, API-Calls pro Monat. Sie brauchen neben dem Limit einen Zähler für die aktuelle Nutzung, und dieser Zähler ist der Teil, den Teams falsch machen. Zeilen zur Request-Zeit zu zählen funktioniert, bis die Tabelle groß wird. Ein materialisierter Nutzungszähler, der bei Create und Delete aktualisiert wird, ist langweilig und korrekt.
Soft Limits erlauben dem Kunden, die Zahl zu überschreiten und die Differenz zu bezahlen. Nutzungsbasiertes API-Pricing ist der Klassiker. Der Enforcement-Punkt blockiert nicht, er zeichnet auf. Deine Metering-Tabelle wird zum Billing-Input, also muss sie append-only und auditierbar sein, denn Kunden werden Rechnungen anfechten, und „wir haben es aus den Events neu berechnet" ist die einzige Antwort, die das Gespräch beendet.
Hard Limits blockieren, und Blockieren muss kontrolliert abfedern. Ein erreichtes Seat-Limit darf nie den ganzen Workspace sperren. Bestehende Seats funktionieren weiter, neue Einladungen schlagen mit einer klaren Meldung und einem Upgrade-Pfad fehl. Das 101. Projekt wird abgelehnt; die ersten 100 bleiben unangetastet.
Ein brauchbares Schema ist kleiner, als die meisten Teams erwarten:
plans (id, code, name, version)
entitlements (id, plan_id, feature_key, limit_value, limit_type)
-- limit_type: boolean | quota_soft | quota_hard
account_overrides(id, account_id, feature_key, limit_value, limit_type,
reason, created_by, expires_at)
usage_counters (account_id, feature_key, period, current_value)
Auflösungsreihenfolge: Override, dann Plan-Entitlement, dann Deny by default. Die Spalten reason und created_by auf Overrides sind keine Dekoration. Wenn in einem Jahr jemand fragt, warum Account 4711 500 Seats hat, sollte die Zeile antworten.
Entitlements in Symfony durchsetzen, ohne die Business-Logik zu verschmutzen
Die Versuchung in einem Symfony-Backend ist, den Entitlement-Checker in jeden Service zu injizieren und überall Guards zu verstreuen. Widersteh ihr. Business-Logik sollte sich wie Business-Logik lesen.
Zwei Patterns halten das Enforcement am Rand. Für boolesche Entitlements erledigen ein PHP-Attribut auf der Controller-Action plus ein Event-Listener auf kernel.controller_arguments den Job:
#[RequiresEntitlement('audit_log')]
public function exportAuditLog(Request $request): Response
Der Listener löst den Account aus dem Request-Kontext auf, fragt den Entitlement-Service und wirft eine 403 mit einem maschinenlesbaren Fehlercode (entitlement_missing:audit_log), den das Frontend in einen Upgrade-Prompt statt in eine Sackgasse verwandeln kann.
Quota-Checks brauchen Argumente, ein Attribut passt daher schlecht. Setze sie in einen dedizierten Guard, der am Anfang des Use Case aufgerufen wird:
$this->quotaGuard->assertCanCreate($account, 'projects');
Eine Zeile am Anfang des Handlers, und die eigentliche Logik zur Projekterstellung bleibt sauber. Der Guard besitzt auch den Metering-Write für Soft Limits, sodass Aufzeichnung und Enforcement nicht auseinanderdriften können.
Cache das aufgelöste Entitlement-Set pro Account mit kurzer TTL und invalidiere bei Planwechsel und Override-Änderung. Entitlements werden bei jedem Request gelesen; die Plan-Tabelle nicht.
Wenn deine aktuelle Codebasis bereits überall verstreute Plan-Conditionals hat, eignet sich dieses Refactoring gut für einen inkrementellen Strangler-Ansatz. Es ist die Art struktureller Arbeit, die wir in Engagements zur Legacy-Code-Optimierung machen: den Service einführen, neue Checks darüber routen und alte Aufrufstellen in Batches migrieren, mit Testabdeckung, die die Parität beweist.
Das React-Hook-Pattern, das Flackern vermeidet
Frontend-Gating hat einen dominanten Fehlermodus: das Aufblitzen. Die App rendert, Entitlements laden einen Takt später, und Pro-Features ploppen vor den Augen des Nutzers rein oder raus. Es sieht kaputt aus, weil es kaputt ist. Der Client hat gerendert, bevor er die Antwort kannte.
Die Lösung ist, Entitlements zum Teil des Session-Payloads zu machen statt zu einem separaten Fetch. Der Endpoint oder die Server-Komponente, die den authentifizierten Nutzer liefert, sollte das aufgelöste Entitlement-Set in derselben Response mitliefern. Hydriere es einmal in einen Context-Provider, dann macht ein Hook die Checks trivial:
const { has, limit } = useEntitlements();
if (!has('audit_log')) return <UpgradePrompt feature="audit_log" />;
Da die Daten mit der Session ankommen, gibt es keinen Ladezustand und nichts, das flackern könnte. Komponenten rendern entweder das Feature oder den Upsell, deterministisch, beim ersten Paint. In einer Next.js-App kannst du Entitlements serverseitig auflösen und durch das Layout durchreichen, womit sogar das initiale HTML korrekt ist.
Halte das Frontend ehrlich in Bezug auf das, was es ist: ein Spiegel der Backend-Entscheidung, nicht die Entscheidung selbst. Die 403 mit entitlement_missing bleibt das Enforcement. Wenn die beiden je auseinanderlaufen, gewinnt das Backend, und das Frontend hat einen Bug.
Plan-Migrationen und Overrides pro Kunde
Die operative Hälfte des Feature Gating ist der Punkt, an dem die meisten Artikel aufhören und der meiste Schmerz wohnt.
Zuerst Plan-Migrationen. Wenn sich das Pricing ändert, mutiere nie die bestehende Plan-Zeile. Lege einen neuen versionierten Plan an (pro_2026), richte neue Signups darauf aus und lass bestehende Accounts, wo sie sind. Bestehende Accounts zu migrieren ist dann ein expliziter Batch-Job mit drei Eigenschaften: Er ist umkehrbar, er loggt den Vorher- und Nachher-Zustand pro Account, und er rechnet den Entitlement-Diff durch, bevor er irgendetwas anfasst. Würde ein Account eine Fähigkeit verlieren, die er aktiv nutzt, kommt dieser Account auf eine Ausnahmeliste für eine menschliche Entscheidung, nicht in den Batch. Stille Downgrades zahlender Kunden erzeugen die wütendsten Tickets, die du je lesen wirst.
Dann Overrides. Enterprise-Deals werden immer individuelle Limits brauchen, also baue das Admin-Tooling, bevor Sales es zum Quartalsende braucht. Die minimal brauchbare Version ist eine Tabellenansicht pro Account mit den effektiven Entitlements und ihrer Quelle (Plan oder Override), ein Formular zum Anlegen eines Overrides mit Pflichtfeld für den Grund und ein optionales Ablaufdatum für Trial-artige Ausnahmen. Ein halber Tag Symfony-Admin-Arbeit, und der Anreiz, Kundenausnahmen in Code zu hacken, ist dauerhaft beseitigt.
Eine Warnung aus Erfahrung: Overrides akkumulieren. Prüfe sie quartalsweise. Ein Override, der auf 30 Prozent der Accounts existiert, ist stillschweigend dein echter Plan geworden, und die Pricing-Seite lügt darüber.
Wo anfangen
Wenn du die Billing-Schicht eines neuen Produkts baust, setze den Entitlement-Service von Anfang an ein; er kostet am Anfang eine Woche Arbeit und ein Quartal, sobald vierzig Aufrufstellen existieren. Diese Schicht ist ein Standardbestandteil unserer Herangehensweise an individuelle Softwareentwicklung für SaaS-Produkte. Hast du die vierzig Aufrufstellen bereits und eine Preisänderung auf der Roadmap, sagt dir ein Audit der bestehenden Gating-Logik, wie tief die Kopplung geht, bevor du dich auf einen Zeitplan festlegst. Das ist ein typischer Scope für unser Code-Quality-Consulting.
Fragen zu deinem eigenen Entitlement-Setup oder eine Pricing-Migration, die du lieber nicht zweimal machen willst? Schreib an hello@wolf-tech.io oder schau dich auf wolf-tech.io um. Wir haben genau diesen Knoten mehr als einmal entwirrt.

