Laravel Tenants Without a Tenancy Package: Rentlytics
A tenant-like product does not automatically need a tenancy package. Sometimes the cleaner cut is not a generic tenant resolver. It is a plain Laravel module with a hard boundary.
Rentlytics is that kind of case: a Laravel 12 platform for municipal rent index calculators, with several live variants, Lit Web Components, hosted delivery, and iframe embedding for city websites. From the outside it looks like multiple calculators. Inside, it stays one codebase.
The problem: multiple calculators, not multiple apps
Cities and municipalities need their own domains, branding, assets, rent index data, and sometimes calculator logic. That can sound like tenancy. Codeäffchen still does not deploy a separate Laravel app per city.
The Rentlytics case study states the technical cut plainly: one codebase with a module architecture, domain-based routing, iframe embedding, Lit Web Components, and Pest browser tests per module. That is not an architecture slide. It is how the platform runs.
The Laravel bootstrap shows the same boundary. Separate modules are registered for Heilbronn, Dueren, Wesel, Hoernergruppe, Wismar, and Minden. Each module joins the same app, but brings its own product variant.
Why a tenancy package is not the first answer here
Rentlytics is not mainly a “which database belongs to which tenant?” problem. It is a product-variant problem: a different public route, layout, assets, JSON data, and calculator behavior.
A tenancy package would not be universally wrong here, but it would be the wrong first abstraction. It would add a generic tenant layer while the real code boundary is already more concrete: the module.
The practical rule: use the smallest architecture that preserves the real boundaries. For Rentlytics, that boundary is the module, not a generic tenant resolver.
The Laravel layer: provider, route, view
Minden shows the spine of the architecture well. The module registers its own Laravel pieces:
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');
}
}
That gives the module four useful boundaries: config under the minden namespace, its own route provider, views under minden::..., and anonymous Blade components under the module prefix.
The domains stay inside the module too:
$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");
});
});
});
So the module owns its public route. There is no central controller deciding via if ($tenant === ...) which city should be shown. Those controllers look harmless at first and later turn into a switchboard for everything.
The product UI stays inside the module
The Blade page is intentionally thin:
<x-minden::layouts.app>
<mietpreisrechner-minden></mietpreisrechner-minden>
</x-minden::layouts.app>
Laravel chooses the right page through domain and module. After that, the module-specific Web Component mietpreisrechner-minden owns the calculator UI.
The assets stay on the same side of the boundary. The layout loads @vite(['modules/Minden/Resources/css/minden-styles.css', 'modules/Minden/Resources/js/minden.js']), so Minden gets its own CSS and JavaScript files. No global frontend bundle where every city carries the styles of every other city.
Embedding is its own boundary
Rentlytics keeps embedding separate from tenant logic. The module route providers attach their routes to Route::middleware(['web', 'embeddable']). In bootstrap/app.php, embeddable maps to App\Http\Middleware\AllowIframeEmbedding. That middleware sets Content-Security-Policy: frame-ancestors *.
The product reason is simple: calculator pages are meant to be embedded by municipal websites. The marketing routes in routes/web.php stay separate and serve the central Rentlytics site with imprint and privacy pages.
When this pattern fits
This pattern fits when tenants are few enough to be explicit in code and differ mostly by public route, branding, assets, data, and domain behavior.
It also fits when releases stay central and there is no tenant self-service admin that needs to create customers, users, subscriptions, or data spaces at runtime.
It does not fit when hundreds of tenants need runtime onboarding, tenant-specific users, tenant-specific billing, or database isolation. Then the problem really is tenant lifecycle and data isolation. Use different tools for that.
What to take into your Laravel project
- model the real boundary first;
- keep module routes inside the module;
- keep views, assets, data, and tests beside the variant they describe;
- avoid central
if ($tenant === ...)controllers; - reach for tenancy packages only when the problem is truly tenant lifecycle or data isolation.
Rentlytics shows what that module boundary looks like in a real product. If your Laravel project needs the same kind of cut, we build Laravel projects and custom software around those boundaries.
Frequently Asked Questions
Does a multi-tenant Laravel product always need a tenancy package?
No. A tenant-like product does not automatically need a tenancy package. For Rentlytics the real boundary is the module, not a generic tenant resolver. The practical rule is: use the smallest architecture that preserves the real boundaries. A tenancy package would be the wrong first abstraction here, because the variants differ mostly by public route, branding, assets, data, and calculator behavior.
How does Rentlytics separate multiple calculators in one Laravel codebase?
Through module service providers. Each module – Minden, for example – registers its own Laravel pieces: a module-local config, its own route provider, views under its own namespace, anonymous Blade components, and the associated domains. The module owns its public route, so there is no central controller. Assets are loaded module-locally via @vite rather than in a global frontend bundle.
When does the module pattern fit instead of a tenancy package?
When tenants are few enough to be explicit in code and differ mostly by public route, branding, assets, data, and domain behavior – and when releases stay central with no runtime tenant self-service. It does not fit when hundreds of tenants need runtime onboarding, tenant-specific users, tenant-specific billing, or database isolation. Then the problem really is tenant lifecycle and data isolation, which calls for different tools.
Why are central if-tenant controllers a problem?
Because each module should own its public route, rather than a central controller deciding via a conditional which city to show. Those controllers look harmless at first and later turn into a switchboard for everything. Instead, routes, views, assets, data, and tests sit beside the variant they describe – which keeps the boundaries clean and the codebase maintainable.
Related Services
Related Projects
Sr. Full-Stack Developer
Frontend & Backend. Builds scalable web applications with a focus on clean architecture and maintainability.