I spent years building things for one site. One install, one admin, one set of options, one database where every row was implicitly mine. Then I started building commercial addons on top of Booknetic SaaS — where one install serves hundreds of separate businesses, each one a tenant who never sees the others — and almost every reflex I had turned out to be quietly wrong.
This isn’t a tutorial on the platform. Booknetic provides the SaaS layer: the tenant model, the capability system, the lifecycle hooks. What I want to write about is the mental shift — the three assumptions multi-tenancy forced me to unlearn, told entirely through my own code.
Assumption 1: every feature is just on
On a single site, a feature exists or it doesn’t. You ship the code, the code runs. In a SaaS, a feature is something a tenant is entitled to — usually tied to whatever plan they’re paying for. The free tenant and the agency tenant run the exact same code, but they must not get the exact same powers.
Booknetic’s SaaS layer exposes a capabilities system for exactly this. My CNAME addon — the one that lets each tenant point their own domain at their booking page — registers a capability the platform then attaches to plans. Concretely, custom domains are gated behind a cname_custom_domain capability that I register through Booknetic’s API; subdomains are free, but bring-your-own-domain is a paid tier. The platform owns the plan-to-capability mapping. I just declare the capability and then, at every entry point that could let a tenant add a custom domain, I ask: is this tenant allowed?
The lesson that took me longest: the gate is not one check. It’s a check at every surface. The tenant panel button, the AJAX handler behind it, the validation, the cron job that later acts on the row. A button hidden in the UI is not a security boundary — anyone can replay the request. So the capability check lives at the write path, not the paint path. On a single site I never thought this way, because there was never anyone to gate.
Assumption 2: my data is only mine
This is the one that will bite you in production if you let it. On a single site, SELECT * FROM my_table is a complete, correct query. In a SaaS, that same query is a data breach — it returns every tenant’s rows at once.
Booknetic handles this with a multi-tenant trait on its ORM models. My domain model is about as small as a class gets:
class CnameDomain extends Model
{
use MultiTenant;
protected static $tableName = 'cname_domains';
protected static bool $timeStamps = true;
}
That one use MultiTenant; line is the whole point. The platform’s trait adds a global scope so that, inside a tenant’s request, CnameDomain::where(...) is automatically constrained to that tenant. Tenant A literally cannot query Tenant B’s domains through the model — the scope rewrites the query before it runs. The first time I really understood it, I deleted a pile of manual where('tenant_id', $id) clauses I’d been sprinkling everywhere out of single-site paranoia. The platform was already doing it, and doing it more reliably than I was.
But scoping isn’t only about reads. It changes how you think about invariants that span a tenant. My addon has a rule: a tenant may have several domains, but exactly one is primary — the canonical URL everyone gets redirected to. Enforcing “exactly one” is a per-tenant operation, so I kept it as a pure function that takes the target and the tenant’s full set of domain ids and returns the new flag for each:
public function plan(int $targetId, array $tenantDomainIds): array
{
if (! in_array($targetId, $tenantDomainIds, true)) {
throw new \InvalidArgumentException('Target domain does not belong to the tenant.');
}
$map = [];
foreach ($tenantDomainIds as $id) {
$map[$id] = ($id === $targetId) ? 1 : 0;
}
return $map;
}
Notice the guard: setting a primary that doesn’t belong to the tenant throws. On a single site that check is absurd — of course it’s yours. In a multi-tenant world it’s the difference between a feature and an exploit.
Per-tenant branding: a different front door for everyone
The most visible part of multi-tenancy, to the end customer, is that each tenant gets their own address. Out of the box a SaaS tends to route by path — example.com/site-a, example.com/site-b. My CNAME addon gives each tenant either a subdomain (site-a.example.com) or a fully custom domain (book.site-a.com) with DNS verification.
Resolution turns out to be a beautifully self-contained problem if you keep it pure. The resolver takes nothing but the incoming host and the base domain, consults the verified rows, and returns a verdict — pass through, redirect, 404, or this is tenant N:
$row = $this->lookup->findVerified($candidate);
// ...
return ResolutionResult::tenant((int) $row['tenant_id']);
The whole point of “per-tenant branding” is captured in that last line: an arbitrary hostname coming off the wire gets mapped to one tenant id, and from that moment the request belongs to them. The custom domain has to be proven first, of course — a tenant can’t just claim a domain they don’t own. So there’s a DNS TXT verification step: the platform-style flow issues a token, the tenant adds a booknetic-verify=<token> TXT record, and a state machine promotes the row from pending to verified only when the record actually resolves. Until then the host doesn’t route anywhere. Branding in a SaaS is never “type a domain and you’re done” — it’s “prove you own it, then we’ll trust it.”
The new failure mode: when there is no current tenant
Here’s the bug class that simply does not exist on a single site, and the one I respect most now.
Most of the time your code runs inside a tenant’s request, so “the current tenant” is a well-defined thing the platform tracks for you. But not always. A super-admin creating a tenant from the backend. A signup flow. A cron job grinding through every tenant in sequence. A CLI task. In all of those contexts, there is no current tenant — and if you rely on the convenient automatic scoping I praised above, it bites back. Automatic scoping is built to infer the current tenant for a write, and in these contexts the current tenant is null.
This is exactly where I got burned, and the fix is to be explicit about who a write belongs to. When my addon auto-creates a tenant’s subdomain in response to the platform’s tenant-created lifecycle hook — which fires in an admin/signup context — I opt out of the automatic scope and pass the tenant id myself:
CnameDomain::noTenant()->insert([
'tenant_id' => $tenantId,
'hostname' => $hostname,
'type' => DomainType::SUBDOMAIN,
'verification_status' => VerificationStatus::VERIFIED,
// ...
]);
The noTenant() call tells the ORM: don’t try to infer the tenant from the current request — I’m telling you which one it is. Once you accept that scoping is keyed off a “current” tenant, it’s obvious why a write from a context that has no current tenant has to name its tenant explicitly, or it has nothing correct to write. The same discipline applies on cleanup: when a tenant is deleted I delete their rows by explicit id, again stepping outside the request scope, because a deletion job has no “current” tenant either.
The shape of the realization: in single-site code, the answer to “whose data is this?” is always “the site’s,” so you never ask. In multi-tenant code, that question has to have an answer at every write, and the dangerous moments are precisely the ones where the framework can’t answer it for you. Reads scope themselves. Writes from the edges — admin, cron, signup — have to name their tenant out loud.
What actually changed in my head
Three habits, all gone. I stopped assuming there’s a current user. I stopped assuming my rows are only mine. I stopped assuming there’s one front door. What replaced them is a single question I now ask of every feature before I write a line: for which tenant, and are they allowed? On a single site that question is noise. In a SaaS it’s the whole job — and honestly, building inside that constraint has made me a sharper engineer on single sites too.
Most of the addons that came out of this thinking ended up at code-heaven.com/v/corelabs (mirrored from addons.itahir.com). The CNAME one is still the one I’m proudest of, precisely because almost none of its hard parts are visible to the tenant who just sees their own domain in the address bar.