Whity runs many organisations — tenants — out of one deployment and one PostgreSQL database. That's a deliberate choice: it's cheaper to operate, easier to upgrade, and means every tenant is always on the same version of the software. It also means the one thing that absolutely cannot fail is isolation. If tenant A can read tenant B's data because of a missed WHERE clause somewhere, "logical multi-tenancy" is just a word for "shared database with a bug." So isolation isn't enforced once. It's enforced three times, at three different layers, on the theory that any one of them failing alone should still not be enough to leak data.
Layer one: reject before the handler runs
Every request passes through EnforceTenantIsolation, an HTTP middleware that runs before any route handler. It resolves the tenant from the request's JWT and rejects outright if the request is trying to act outside that tenant's scope. This is the coarse filter — it doesn't know anything about the specific resource being requested, only that the caller is who their token says they are, scoped to the tenant their token says they belong to.
Layer two: predicates in the query, not a convention in someone's head
The middleware alone doesn't stop a handler from writing a query that forgets to filter by tenant. So the second layer lives in the data access code itself: every handler and repository binds an explicit tenant_id predicate from the currentTenantContext. It's not optional and it's not implicit — there's a real-engine test suite whose entire job is attempting cross-tenant reads against every tenant-owned table and asserting they're rejected. A table that's supposed to be tenant-scoped but isn't wired into that predicate is a test failure, not a silent gap.
Layer three: the context has to be trustworthy across requests
This is the layer that's easy to get wrong for a reason that has nothing to do with multi-tenancy logic and everything to do with how the runtime works.
Whity's backend runs on FrankenPHP with a pool of persistent PHP workers, not one-process-per-request. That's good for performance — no bootstrap cost on every request — but it means anything held in a worker's memory outlives the request that set it, unless something explicitly resets it. TenantContext is resolved from the JWT at the start of a request, locked for the duration of that request, and reset before the worker picks up the next one. Get that reset wrong, even in one code path, and a persistent worker can serve request N+1 with request N's tenant still attached — a much quieter failure mode than a missing WHERE clause, and a good reminder that "multi-tenant" and "persistent worker pool" are two architectural decisions that have to be designed together, not layered on independently.
tenant_id = 0) sits outside this per-tenant isolation by design — it's the one identity in the system allowed to act across tenant boundaries, for platform-level operations like provisioning a new tenant in the first place.The other kind of boundary: plugins
Tenant isolation keeps organisations apart from each other. A separate boundary keeps domain logic apart from the platform itself. Whity Core ships almost no domain-specific behaviour — no invoicing, no scheduling, nothing industry-specific. That lives in plugins, dropped into /plugins/, discovered via reflection and moved through a lifecycle:
A plugin is hot-loaded — no server restart to pick it up or drop it — and runs behind an error boundary. If it throws during initialization or repeatedly at runtime, it moves tofailed and can be disabled without taking the rest of the platform down with it. That matters more than it might sound: in a system where third parties are expected to write plugins, "one plugin's bug degrades gracefully" is a correctness requirement, not a nice-to-have.
Why plugins can't reach into core, structurally
The interesting part isn't the error boundary at runtime — it's what a plugin is allowed to depend on at all. Plugins are written against whity/plugin-sdk, a package that ships separately from core and depends on nothing but PHP itself. It defines interfaces, data shapes, HTTP contracts, and event constants. Nothing else.
use Whity\Sdk\PluginInterface; // the contract
use Whity\Sdk\Http\Request; // what handlers receive
use Whity\Sdk\Http\Response; // what handlers return
use Whity\Sdk\MigrationInterface; // up(\PDO) / down(\PDO)
// Whity\Core\* is never imported. It isn't in the dependency tree.Core's own HTTP layer is built the same way core wants plugins to be built: itsRequest and Response classes are subclasses of the SDK's. A route handler in core and a route handler in a plugin return the same shape of object, but the plugin only ever imported the SDK's parent class — it has no path to core internals, because there's no core code in its dependency tree to import. It's not a documented convention that plugin authors are trusted to follow. It's the only thing the autoloader gives them.
Why this boundary is the interesting part of the licence, too
Whity Core is AGPL-3.0: modify it, self-host it, run it commercially, but if you serve a modified version to users over a network, they're entitled to its source. The Plugin SDK is MIT, and a plugin that only uses the SDK's interfaces — never core's source — can be licensed however its author wants, closed-source and sold included. That split only holds together legally because it's true architecturally: a plugin author isn't being asked to trust a line in a licence document, they're depending on a package that genuinely contains no core code to copy in the first place. The details of what that means for a plugin author are on the licence page.