Appearance
Tenancy
The active organization is the tenant a request is scoped to. Getting this wrong is the most expensive class of bug a multi-tenant application has, so the kit gives it one contract, one implementation, and one place that changes it.
Ask the contract, never the session
php
use Laraspring\Core\Contracts\TenantResolver;
$tenant = app(TenantResolver::class)->current(); // ?object
$id = app(TenantResolver::class)->id(); // int|string|nullThe rule that must never break
No package reads session('organization_id'), a route parameter or a subdomain to find the tenant. Ever. It resolves TenantResolver and asks.
laraspring/organizations is the single exception, because it is the implementation. Everything else, your own code included, goes through the contract.
The reason is not style. A second place that decides what the active tenant is, is a tenant boundary waiting to be crossed: the two disagree, one of them scopes a query and the other stamps a record, and data ends up in the wrong organization. The binding is a singleton for the same reason.
id() is answered straight from the session with no query, which is what most callers actually want: scoping a query or writing a foreign key needs the key, not the record.
Null is a normal answer
A guest, a console command, a single-tenant installation and a signed-in user who has not picked an organization yet all legitimately have no tenant. current() returns null and does not throw. A caller that cannot proceed without one raises its own exception.
With laraspring/organizations absent, laraspring/core binds NullTenantResolver and every package resolves the contract unconditionally, with no "is multi-tenancy installed" branch anywhere.
Setting the tenant does not authorise it
set() makes an organization active and asks no questions. That is by design and stated in the contract: the caller is the only thing that knows in whose name it is acting.
Which puts the responsibility somewhere specific. organizations.switch is the single point at which the active tenant changes, and it is where membership is established:
php
abort_unless($organization->hasMember($request->user()), 404);
Organizations::activate($organization);A 404 rather than a 403, because whether an organization exists is not something a stranger gets to learn by guessing identifiers.
No other route takes an organization in its URL. If you add one that changes the tenant, you have added a second place that can forget the check.
Membership is revalidated on every request
Establishing membership at switch time is worth exactly one request. Being removed from an organization does not reach into the session of somebody who is already signed in, so without a per-request check an expelled member keeps working inside the tenant they were holding until they happen to log out.
EnsureActiveOrganizationMembership is appended to the whole web group by the package's service provider, not left for you to add to the routes you remember. It:
- clears the tenant and redirects to the picker when the user is no longer a member, or the organization is gone;
- answers a
409to an XHR, which cannot follow a redirect into a picker; - quietly drops a tenant left in a guest's session;
- costs nothing on a request with no active tenant, which is most of them.
An alias, organization.member, is registered for a route of yours outside the web group.
Deleting an organization
Deleting one clears the actor's own session and removes memberships and invitations explicitly, rather than relying on the schema's cascades, which SQLite only enforces when the connection asks it to. Everybody else's session still names it, and no request can reach into another user's session; the middleware above is what clears theirs on their next request.
Where a finished flow lands
laraspring/organizations decorates DestinationResolver rather than replacing it, so afterLogoutUrl() and whatever your edition configured keep working. Only the authenticated landing page is reconsidered:
| The user has | They land on |
|---|---|
no organizations, optional mode | the wrapped destination, usually the dashboard |
no organizations, required mode | onboarding |
| exactly one | that one, activated, then the dashboard |
| several, one already active and still theirs | the dashboard, where they left off |
| several, none active | the picker |
None of this is visible to laraspring/auth, which redirects through Destinations::home() and never learns that organizations exist.
Sharing the tenant with your frontend
The reference edition shares it from HandleInertiaRequests, and reads it through the contract like everything else:
php
'organizations' => [
'current' => /* Organizations::current() */,
'all' => /* Organizations::forUser($user) */,
],Laraspring\Organizations\Support\Organizations is sugar over the contract and two queries, so no caller has to remember where the active organization is stored.