Skip to content

Tenancy ​

The SDK's tenancy surface answers one question: which organization operates this installation? The answer is a lightweight, database-free descriptor, the tenant, which SdkContext::tenant() carries through the request. Tenant isolation (keeping one customer's data away from another's) is not the SDK's job; that lives at the deployment boundary and belongs to the host.

The app is the tenant. Single-tenancy is the baseline, not a degenerate case. A standalone install is its own tenant and still owes the shell a real identity. Multi-tenancy is the same model with one extra upstream step: the host switches the database by request domain before resolution runs. The SDK never needs to know which mode it is in.

The SDK is a package you install to build an enterprise app on Laravel. Platform is the operational layer (a database per tenant routed by domain, customer onboarding, backup and restore) that turns it into a vertical SaaS, and we build it with you.

The model ​

Tenancy is four types:

TypeRole
TenantInterfacethe operating organization's identity (name, logo, legal payload)
TenantResolverInterfaceresolves the tenant for the current request
Tenantthe shipped DB-free POPO implementing TenantInterface
MissingTenantResolverExceptiona bespoke resolver signalling it cannot resolve

A Tenant is a curated org-identity subset of a Partner, with only the org-relevant fields:

php
interface TenantInterface
{
    public function getId(): int|string;            // domain- or partner-derived
    public function getExtRef(): string;
    public function getSystemLevel(): SystemLevel;
    public function getName(): string;              // display / legal name
    public function getLogo(): string;              // org Partner avatar_path (storage-relative)
    public function getEmail(): string;
    public function getPhone(): string;
    public function getVatNumber(): string;
    public function getRegistrationNumber(): string;
    public function getRegisteredOffice(): string;
    public function getFoundationDate(): ?Carbon;
}

Person fields (first_name, gender, birthdate, …) are pruned. So is locale: it is an installation detail carried by the request (request()->getLocale()), not a property the tenant owns.

Tenant ≠ the actor's organizations ​

SdkContext distinguishes two org-shaped things, and they do not collide:

  • tenant(): who operates this installation (the operator company).
  • orgPartners(): who the logged-in person represents (the actor's employer(s)).

They coincide when the operator dogfoods its own app. They diverge on a SaaS where a consultant from one company logs into an installation operated by the vendor.

The default resolver ​

Since SDK 1.2.0

Releases up to 1.1.1 ship a default that returns a fictional ACME operator and reads no database. On those releases, bind your own resolver (below) to see your organization.

Out of the box the SDK binds TenantResolverInterface to DefaultTenantResolver. It projects the installation's platform owner into a Tenant: the organization ui5:intake created, which is also the partner behind ui5.system_actor_id. The tenant of a fresh installation is therefore the organization you named in ui5:intake, with no further wiring.

TenantInterfacefrom the platform owner's sdk_partners column
getId()id
getExtRef()ext_ref
getSystemLevel()system_level (PlatformOwner)
getName()name
getLogo()avatar_path
getEmail()email
getPhone()phone
getVatNumber()vat_number
getRegistrationNumber()company_registration_number
getRegisteredOffice()registered_office
getFoundationDate()foundation_date

A column the partner leaves empty comes through as an empty string. ui5:intake has no option for the logo, so getLogo() stays empty until the partner has an avatar. The partner's search_term and language_code are not part of the tenant. Without a platform owner, the resolver fails the same way ui5:sync does, with MissingSystemActorException. Run ui5:intake first.

A host that wants a different operator identity rebinds the resolver:

php
// AppServiceProvider::register()
$this->app->singleton(
    \LaravelUi5\Sdk\Tenancy\Contracts\TenantResolverInterface::class,
    \App\Tenancy\OperatingOrgResolver::class,
);

In a multi-tenant deployment, the host's tenancy layer switches the database by domain upstream of this resolver. The resolver then reads the operating org from whatever database the request is already connected to. Selecting an operator across tenants is a Platform concern, not this surface.

MissingTenantResolverException is reserved for bespoke resolvers, such as a domain-based resolver in a multi-tenant host signalling an unresolvable tenant (an un-provisioned domain, a missing upstream DB switch). The shipped default does not use it.

Creating the operating organization: ui5:intake ​

php artisan ui5:intake creates the installation's platform-owner organization, the single SystemLevel::PlatformOwner partner of the database. It is the system actor: ui5:sync records it as set_by on every setting default it seeds. Run intake once per database, before the first sync:

php artisan migrate
php artisan ui5:intake
php artisan ui5:sync

Every property is an option (--name, --email, --vat, --office, …) and otherwise a prompt. There are no defaults: --name and --email are required, everything else stays empty unless you pass it. The command is idempotent and keyed on --email: a re-run updates the owner in place, and a second owner under a different email is refused.

The same partner is your tenant: the default resolver projects it.

Invariants ​

  • Resolve once, immutable. resolve() runs once per request, when the SDK builds the context for a module that requires authentication; the result lives in the readonly SdkContext. There is no tenant switching mid-request: no setter, no re-resolve. A module that does not require authentication gets Core's context, which has no tenant.
  • Never a query dimension. The SDK adds no tenant_id column, ever. Isolation is per database (1 client = 1 DB plus a separate filesystem), never row-level. The tenant is derived within the already-isolated database, never selected across tenants.
  • The resolver is the only seam. The SDK depends on TenantInterface and TenantResolverInterface and nothing else. The shipped default couples to one partner, the platform owner. A host resolver may couple to whatever it needs, but that coupling lives in the host.
  • Branding does not come from the tenant yet. The shell's sidebar name and logo come from a candidate chain, not from the tenant — so a multi-tenant host writes a candidate that resolves the tenant itself. The logo and the name beside it shows how. Tenant-driven branding is planned.
  • A default always exists. Once ui5:intake has run, the platform owner is the tenant, so single-tenancy is served out of the box.

See also ​