Panels
Tenancy
A tenant in the URL — found by its slug, checked against the signed-in person, entered, and carried into every link the framework builds.
On this page
A tenant is a company the application serves, and a tenant zone is a group of
pages that lives under one: /app/acme/orders, or acme.example.com/orders.
This page is the routing half — how the tenant gets from the URL into the
request. Which rows a tenant sees is the data half, and it is
Authorization § Multi-tenancy.
How It Works
The tenant is a route parameter named tenant, in the prefix or in front of the
domain, and the wire.tenant middleware turns it into the current tenant. On
every request inside the zone it does four things, in this order:
- Finds the tenant by the model's route key —
getRouteKeyName(), a slug when the model says so — without the model's global scopes. - Checks the signed-in person:
HasTenants::canAccessTenant($tenant). A stranger, an unknown slug and a guest all get the same 404 — a 403 would tell whoever typed/app/globex/that globex exists. - Enters it (
CurrentTenant), so every model usingBelongsToTenantis scoped to it with nothing else written. - Carries it:
URL::defaults(['tenant' => …]), so every URL built after this point —urlFor(),OrderResource::url(), the menu, search results, breadcrumbs, redirects after save, your ownroute()calls — points into the same tenant. The parameter is then taken off the route, so no page'smount()is handed one it did not ask for.
A Livewire round trip keeps it. The update request goes to
livewire/update, which has no {tenant} in it; the middleware is registered
as Livewire persistent middleware, so Livewire rebuilds the page's original
request and runs it again. The second render is scoped and linked exactly as the
first was.
The traps. The zone survives a round trip the same way, on every page
Route::wireResources() registered; a component on a route of your own keeps
the zone it read on mount and passes it (Routing).
And a user model that does not
implement HasTenants is refused with an exception on the first request, never
read as "may enter every tenant".
A Tenant Zone
One option on the panel's route group does all of it — the parameter, the middleware, and the zone's own address beside it. In a route file:
Route::middleware(['web', 'auth'])->prefix('app') // or ->domain('example.com') ->group(fn () => Route::wire('panel', zone: 'app', tenant: 'path'));
Or as an entry of wire-core.routes.groups (Routing):
// config/wire-core.php'routes' => ['groups' => [ 'app' => ['uses' => 'panel', 'prefix' => 'app', 'tenant' => 'path'], // app/{tenant}/… 'portal' => ['uses' => 'panel', 'domain' => 'example.com', 'tenant' => 'domain'], // {tenant}.example.com]],
Written out by hand it is the same group, with the parameter and the middleware in it yourself:
Route::name('app.')->prefix('app/{tenant}')->middleware(['web', 'auth', 'wire.tenant']) ->group(fn () => Route::wireResources());
Nothing here assumes tenancy. A zone without tenant is an ordinary zone,
and a page that only exists inside a company — the companies module's profile
and members — is skipped in it, so an application that does not use tenancy
never carries an address that could only answer 404.
A path needs nothing from DNS and lets a person keep two tenants open in two
tabs; a domain needs a wildcard DNS record and certificate, and a session
cookie domain that covers the subdomains (SESSION_DOMAIN=.example.com). A
value other than path or domain, or domain on a zone with no domain, is
refused when the routes are registered.
The Zone's Own Address
Every page of a tenant zone lives under a company, so /app on its own would
be a 404 — and it is the address a person types and the one signing in lands
on. It answers instead: to the person's getDefaultTenant(), and from there to
the first page of that company they may open.
tenant: registers it by itself, at the prefix without the tenant or at the
domain's root, named {zone}.wire.tenants — which is also what a zone picker
offers for the zone. A tenant zone written out by hand places it as its own
group, saying where a company's address is:
Route::name('app.')->middleware(['web', 'auth']) ->group(fn () => Route::wire('tenant-entry', uri: 'app', to: 'app/{tenant}'));
to has {tenant} where the company's key goes — //{tenant}.example.com for a
domain zone.
Someone with no company at all gets wire-panels.routes.tenant_entry.view when
the application names one — the place to offer registering a company — and a
403 that says why when it does not. The companies module
names its own when the application has not.
The Company Switcher
Inside a company, the top bar of any shell that draws PageChrome::TOPBAR gets
a switcher, from wire-panels. It lists the companies this person belongs to,
each linked to the same page in that company — except on a record's page,
where the link is the same resource's list: record 7 of one company is not a
record of another, and switching would land on a 404. Nothing is drawn outside
a company, or for someone with only one.
A company is named by wire-core.tenancy.label (name), or by its route key
when that is empty.
The Tenant Model
// config/wire-core.php'tenancy' => [ 'enabled' => true, 'model' => App\Models\Company::class, 'members_table' => 'tenant_user',],
class Company extends Model{ public function getRouteKeyName(): string { return 'slug'; // /app/acme/… rather than /app/12/… }}
Who Belongs Where
The user model answers it, through HasTenants:
use NyonCode\WirePanels\Concerns\InteractsWithTenants;use NyonCode\WirePanels\Contracts\HasTenants; class User extends Authenticatable implements HasTenants{ use InteractsWithTenants; }
InteractsWithTenants reads membership from a many-to-many to the tenant model
over members_table, with Laravel's pivot column names — user_id and, for a
Company, company_id. A membership shaped otherwise overrides tenants(), or
implements the three methods itself:
| Method | Returns | Purpose |
|---|---|---|
getTenants(): iterable |
iterable<int, Model> |
The tenants this person belongs to — what a switcher offers |
canAccessTenant(Model $tenant): bool |
bool |
Whether they may work in this one; asked on every request inside it |
getDefaultTenant(): ?Model |
Model|null |
Where the zone's own address sends them |
Related
- Authorization § Multi-tenancy — the scope, the fail-safe,
runAs()for jobs and commands - Routing — zones, route names and
url() - Navigation — the menu whose links now carry the tenant
- Companies — registering a company, and inviting and managing its members