Skip to content

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|null

The 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 409 to 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 hasThey land on
no organizations, optional modethe wrapped destination, usually the dashboard
no organizations, required modeonboarding
exactly onethat one, activated, then the dashboard
several, one already active and still theirsthe dashboard, where they left off
several, none activethe 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.

Laraspring is a commercial starter kit. Buying it gets you the source.