Type
In-house product
Isolation
One database per tenant
Identification
Subdomain
Platform
Laravel 13, PHP 8.3+
Laravel 13MySQL 8Livewirestancl/tenancy

What it is

InternalPayroll is a multi-tenant payroll SaaS, rebuilt from a legacy Laravel 6 application. Each customer company is a tenant, identified by its own subdomain, with its own physical database.

The decision that shapes everything else

The usual way to build multi-tenant software is one shared database with a company identifier column on every table, and a query scope that filters by it. It is cheaper to operate and it works — right up until one query somewhere forgets the scope.

For most products that bug shows someone another company's records, which is bad. For payroll it shows them salaries, which is a different category of incident entirely.

So the isolation boundary here is the database itself: one central control-plane database plus one database per tenant. There is no company identifier column inside tenant tables, because the database is the tenant. A cross-tenant leak stops being a forgotten `where` clause and starts requiring a connection to the wrong database — a much harder mistake to make by accident, and a much easier one to test for.

The subtlety that isolation alone does not solve

Separating the data is not sufficient, because not everything lives in the database. The permission layer caches its role and permission registry in memory for speed, and that cache does not know what a tenant is.

Without handling, a request that switches tenancy mid-process can answer permission questions using the previous tenant's roles — the data is correctly isolated while the authorisation decisions are not. The fix is a bootstrapper that flushes the permission registrar's cache on every tenancy transition, and it is covered by an isolation test that drives tenancy directly rather than going through DNS, so it runs anywhere.

This is the kind of thing that is invisible in a demo and expensive in production, and it is most of why database-per-tenant is worth the operational overhead: the remaining leaks are in caches and singletons, which are enumerable, rather than in every query anyone will ever write.

Auth built for tiers that do not exist yet

The authentication config is multi-guard from the start, with distinct identity tiers rather than one user table and a role column:

  • A super-admin guard on the central database, logging in on the root domain, rate-limited, with accounts created through an interactive console command so no password is ever hardcoded
  • A tenant guard for company administrators and coordinators, logging in on their own subdomain, rate-limited per tenant, with an active-status check enforced at login
  • An external client guard specified but not yet built — the point of declaring it early is that adding it later cannot require unpicking a single-guard assumption that has spread through the codebase

A note on how decisions get recorded

The build spec pinned one major version of the UI layer; the dependency resolver installed the next one. Rather than silently accepting the newer version or quietly downgrading, the discrepancy was written down in the README with the reasoning — no components had been built yet, so the decision could be deferred safely to the phase where it would first matter.

That is a small thing, but it is the habit that keeps a long build honest. The alternative is a codebase where nobody can tell which versions were chosen and which merely happened.

This is an OffSetup product, so there is no client to anonymise and the figures above are drawn from the project's own engineering documentation.