Architecture & Patterns
Architecture & Patterns
Two layers
Kernel\{Tenancy, Crypto, Audit, Events, Authorization} ← shared primitives
▲
Domain\{Organization, Identity, AccessControl, ← features built on kernels
Directory, Federation, Webhooks, AuditQuery}
▲
Your app / the hosted SaaS shell ← consumes the domain contracts
Kernels depend only on the framework and, in dependency order, each other (Audit signs checkpoints with Crypto; Authorization emits via Events and records via Audit). They own no business concepts.
Domain modules depend on kernels and on other modules' Contracts/ only — never their
internals. A module's public surface is exactly its Contracts/ namespace.
Contracts-first, resolved from the container
Every capability is an interface bound to an implementation in a module service provider:
$this->app->singleton(Subjects::class, DatabaseSubjects::class);
So you always depend on the contract, and you can swap the implementation, decorate it, or mock it without touching callers. See Extending.
public function __construct(private readonly Subjects $users) {}
Deny-by-default everywhere
- Tenancy: a query on a tenant-owned model with no tenant in context returns zero rows, never all of them. A missing tenant can't leak another tenant's data.
- Authorization: the PDP allows only on an explicit relationship grant; anything else denies.
- Crypto: JWT verification takes an explicit algorithm allow-list;
alg=noneand RS↔HS confusion are impossible.
Value objects and enums
Inputs and results are immutable value objects (NewOrganization, FederatedPrincipal,
EntitlementInput, Decision, …) and every status/type is an enum. There are no array-shaped
"config bags" flowing through business logic.
Dogfooding
Modules use the kernels the same way you would, which keeps the primitives honest:
Organizationis aTenant; membership writes run insideTenantContext::runAs().Directorydeprovision callsSessionManager::revokeAllForUser()— SCIM offboarding kills sessions instantly.AccessControlresolves permissions acrossOrganizationHierarchy::ancestors(), so roles roll down from a reseller to the customers it manages.Webhookslistens for the Events kernel'sEventDeliveredand seals endpoint secrets with the CryptoSecretBox.
Hierarchy without weakening isolation
The reseller/parent tree is a closure table (OrganizationHierarchy), never a loosening of
the tenant scope. Cross-org reach is an explicit, authorized elevation:
- Roll-down (a parent's role/entitlement applies to descendants) — resolved via the closure.
- Roll-up (a parent reporting over descendants) —
TenantContext::scopedTo([...keys]), a bounded, authorized read set; deny-by-default on an empty set.
The isolation kernel itself stays strict and unchanged.