Laravel-Mandanten ohne Tenancy-Package: Rentlytics
Ein mandantenähnliches Produkt braucht nicht automatisch ein Tenancy-Package. Manchmal ist der sauberere Schnitt kein generischer Tenant-Resolver, sondern ein bewusst langweiliges Laravel-Modul.
Rentlytics ist so ein Fall: eine Laravel-12-Plattform für kommunale Mietspiegelrechner, mit mehreren live betriebenen Varianten, Lit Web Components, gehosteter Auslieferung und iframe-Einbettung für Stadt-Websites. Die Plattform verhält sich nach außen wie mehrere Rechner. Innen bleibt sie eine Codebase.
Das Problem: mehrere Rechner, keine mehreren Apps
Städte und Kommunen brauchen eigene Domains, eigenes Branding, eigene Assets, eigene Mietspiegel-Daten und teils eigene Rechenlogik. Das klingt schnell nach Mandantenfähigkeit. Codeäffchen deployt dafür aber nicht pro Stadt eine eigene Laravel-App.
Die Rentlytics-Case-Study beschreibt den technischen Schnitt klar: eine Codebase mit Modul-Architektur, domainbasiertem Routing, iframe-Embedding, Lit Web Components und Pest-Browser-Tests pro Modul. Das ist keine Architekturfolie, sondern der Betriebsmodus der Plattform.
Auch im Laravel-Bootstrap ist die Grenze sichtbar. Registriert sind eigene Module für Heilbronn, Dueren, Wesel, Hoernergruppe, Wismar und Minden. Jedes Modul hängt sich in dieselbe App ein, bringt aber seine eigene Produktvariante mit.
Warum hier kein Tenancy-Package die erste Antwort ist
Rentlytics ist nicht zuerst ein Problem von “welche Datenbank gehört zu welchem Tenant?”. Es ist ein Produktvarianten-Problem: andere öffentliche Route, anderes Layout, andere Assets, andere JSON-Daten, anderes Rechnerverhalten.
Ein Tenancy-Package wäre hier nicht grundsätzlich falsch, aber es wäre die falsche erste Abstraktion. Es würde eine generische Mandanten-Schicht einführen, obwohl die echte Grenze im Code schon konkreter ist: das Modul.
Die praktische Regel: Nimm die kleinste Architektur, die die echten Grenzen schützt. Bei Rentlytics ist diese Grenze das Modul, nicht ein allgemeiner Tenant-Resolver.
Die Laravel-Schicht: Provider, Route, View
Minden zeigt das Rückgrat der Architektur gut. Das Modul registriert seine Laravel-Bausteine selbst:
class MindenServiceProvider extends ServiceProvider
{
public function boot(): void
{
$this->loadMigrationsFrom(__DIR__.'/../Database/Migrations');
$this->mergeConfigFrom(__DIR__.'/../config.php', 'minden');
$this->app->register(RouteServiceProvider::class);
$this->loadViewsFrom(__DIR__.'/../Views', 'minden');
Blade::anonymousComponentPath(__DIR__.'/../Views/components', 'minden');
}
}
Das bringt vier Dinge, die im Alltag zählen: eine modul-lokale Config unter dem Namespace minden, einen Route Provider im Modul, Views unter minden::... und anonyme Blade-Komponenten unter dem Modul-Präfix.
Die Domains bleiben ebenfalls im Modul:
$domains = collect([
"minden.ddev.site",
"minden.rentlytics.io",
"minden.test.rentlytics.io",
]);
$domains->each(function ($domain) {
Route::domain($domain)->group(function () {
Route::get("/", function () {
return view("minden::index");
});
});
});
Damit besitzt das Modul seine öffentliche Route selbst. Es gibt keinen zentralen Controller, der per if ($tenant === ...) entscheidet, welche Stadt gerade dran ist. Genau solche Controller sehen anfangs harmlos aus und werden später zum Schalterkasten für alles.
Die Produktoberfläche bleibt im Modul
Die Blade-Seite ist absichtlich dünn:
<x-minden::layouts.app>
<mietpreisrechner-minden></mietpreisrechner-minden>
</x-minden::layouts.app>
Laravel wählt über Domain und Modul die richtige Seite. Danach übernimmt die modul-spezifische Web Component mietpreisrechner-minden die Rechner-Oberfläche.
Auch die Assets bleiben an dieser Grenze. Das Layout lädt per @vite(['modules/Minden/Resources/css/minden-styles.css', 'modules/Minden/Resources/js/minden.js']) genau die CSS- und JavaScript-Dateien des Minden-Moduls. Kein globales Frontend-Bündel, in dem jede Stadt die Styles der anderen mitträgt.
Einbettung ist eine eigene Grenze
Rentlytics trennt Einbettung von Mandantenlogik. Die Modul-Route-Provider hängen ihre Routen an Route::middleware(['web', 'embeddable']). In bootstrap/app.php ist embeddable auf App\Http\Middleware\AllowIframeEmbedding gemappt. Diese Middleware setzt Content-Security-Policy: frame-ancestors *.
Der Produktgrund ist simpel: Die Rechnerseiten sollen in kommunalen Websites eingebettet werden können. Die Marketing-Routen in routes/web.php bleiben davon getrennt und liefern die zentrale Rentlytics-Seite mit Impressum und Datenschutz.
Wann dieses Muster passt
Dieses Muster passt, wenn die Mandanten wenige genug sind, um explizit im Code zu stehen, und sich vor allem über öffentliche Route, Branding, Assets, Daten und Domain-Verhalten unterscheiden.
Es passt auch, wenn Releases zentral bleiben und kein Tenant-Self-Service existiert, der zur Laufzeit neue Kunden, Benutzer, Abos oder Datenräume anlegt.
Es passt nicht, wenn hunderte Tenants zur Laufzeit onboarden, eigene Benutzerwelten verwalten, tenant-spezifisch abrechnen oder Datenbankisolation brauchen. Dann ist das Problem wirklich Tenant-Lifecycle und Datenisolation. Dafür gibt es andere Werkzeuge.
Was du für dein Laravel-Projekt mitnimmst
- modelliere zuerst die echte Grenze;
- halte Modul-Routen im Modul;
- lege Views, Assets, Daten und Tests neben die Variante, die sie beschreiben;
- vermeide zentrale
if ($tenant === ...)-Controller; - greif erst zu Tenancy-Packages, wenn dein Problem wirklich Tenant-Lifecycle oder Datenisolation ist.
Wie diese Modulgrenze in einem echten Produkt aussieht, zeigt Rentlytics. Wenn dein Laravel-Projekt denselben Schnitt braucht, bauen wir Laravel-Projekte und individuelle Software an solchen Stellen sauber aus.
Häufige Fragen
Braucht ein mandantenfähiges Laravel-Produkt immer ein Tenancy-Package?
Nein. Ein mandantenähnliches Produkt braucht nicht automatisch ein Tenancy-Package. Bei Rentlytics ist die echte Grenze das Modul, nicht ein generischer Tenant-Resolver. Die praktische Regel lautet: Nimm die kleinste Architektur, die die echten Grenzen schützt. Ein Tenancy-Package wäre hier die falsche erste Abstraktion, weil sich die Varianten vor allem über öffentliche Route, Branding, Assets, Daten und Rechnerverhalten unterscheiden.
Wie trennt Rentlytics mehrere Rechner in einer Laravel-Codebase?
Über Modul-Service-Provider. Jedes Modul – etwa Minden – registriert seine eigenen Laravel-Bausteine selbst: eine modul-lokale Config, einen eigenen Route-Provider, Views unter eigenem Namespace, anonyme Blade-Komponenten und die zugehörigen Domains. Das Modul besitzt seine öffentliche Route selbst, es gibt keinen zentralen Controller. Auch die Assets werden per @vite modul-lokal geladen, statt in einem globalen Frontend-Bündel.
Wann passt das Modul-Muster statt eines Tenancy-Packages?
Wenn die Mandanten wenige genug sind, um explizit im Code zu stehen, und sich vor allem über öffentliche Route, Branding, Assets, Daten und Domain-Verhalten unterscheiden – und wenn Releases zentral bleiben und kein Tenant-Self-Service zur Laufzeit existiert. Es passt nicht, wenn hunderte Tenants zur Laufzeit onboarden, eigene Benutzerwelten verwalten, tenant-spezifisch abrechnen oder Datenbankisolation brauchen. Dann geht es wirklich um Tenant-Lifecycle und Datenisolation, wofür es andere Werkzeuge gibt.
Warum sind zentrale if-tenant-Controller problematisch?
Weil jedes Modul seine öffentliche Route selbst besitzen soll, statt dass ein zentraler Controller per Fallunterscheidung entscheidet, welche Stadt gerade dran ist. Solche Controller sehen anfangs harmlos aus und werden später zum Schalterkasten für alles. Stattdessen liegen Route, Views, Assets, Daten und Tests neben der Variante, die sie beschreiben – das hält die Grenzen sauber und die Codebase wartbar.
Passende Leistungen
Passende Projekte
Sr. Full-Stack Developer
Frontend & Backend. Entwickelt skalierbare Webanwendungen mit Fokus auf saubere Architektur und Wartbarkeit.