Loading...

This is taking longer than expected.

The team context

How a request knows which team it is in, how the permission guard is chosen, and what to do in a job, a command or a Livewire component.

Every request under /team runs inside one team: the records it lists, the permissions it checks, the billing account it charges. This guide explains the piece of code that decides which team, where that decision is read from, and how to make the same decision yourself where no request exists. Read it before writing a screen, a policy or a job that touches team data.

Where the team is stored

Place What it holds
The team_id cookie The team the person last opened on this browser, so the team list can skip itself on the next visit. Written when they open a team from the list, create one or follow a team link; forgotten on sign-out, and by the team middleware when it names a team they no longer belong to.
users.current_team_id A fallback read only when the cookie names no team of the person's. Nothing in the kit writes it; the team middleware clears it when it no longer leads to a team of theirs.
session('team_id') The team of the current request, 0 for none. Written by the middleware on every request.
Spatie's permission team id The team whose roles and permissions are being consulted. getPermissionsTeamId() reads it, setPermissionsTeamId() writes it.

The helper that everything else uses:

team(): ?Team   // the team of the current request, or null

team() reads App\Classes\CurrentTeam, which AppServiceProvider resolves once per request: the team the team_id cookie names when the person belongs to it, otherwise the team in users.current_team_id when they belong to it, otherwise null. It is null for a signed-out visitor, and on every route named app.*: App\Http\Middleware\PersonalAreaMiddleware, in the web group and persistent on Livewire updates, empties it there, so the personal area /app never works inside a team even when one is remembered.

The two middleware

App\Http\Middleware\TeamMiddleware, alias team, runs on every route under /team:

  1. Takes the team from team(), and forgets the team_id cookie when it does not name a team the person belongs to.
  2. When there is no team, clears current_team_id if it was set and redirects to /app.
  3. Calls setPermissionsTeamId($team->id) and writes session(['team_id' => $team->id]).

App\Http\Middleware\NoTeamMiddleware, alias noTeam, runs on /admin: it sets the permission team id and the session to 0, so the admin panel consults the global roles even when the person has a team open in another tab.

Before step 1 of team, a GET request carrying a team query parameter is a team link: the middleware switches a member to that team and redirects to the same address without the parameter, and sends anyone else to the switcher with a notice. An email or a notification about one team builds its link with Team::routeUrl(), which takes a route name and its parameters like route() and returns an absolute address:

$team->routeUrl('team.media', ['path' => 'reports']); // https://example.test/team/media/reports?team=12

Both are registered in bootstrap/app.php. A route that reads team data and carries neither is a bug: without team, getPermissionsTeamId() is 0 and every check on the team guard fails.

The route groups

They are registered in bootstrap/app.php:

// The switcher: signed in, no team yet. Asks for a subscription only when the
// project sells subscriptions to users.
Route::middleware(['web', 'auth', 'security'])->prefix('app')->name('app.')->group(base_path('routes/app.php'));

// The workspace: inside a team.
Route::middleware(['web', 'auth', 'security', 'team'])->prefix('team')->name('team.')->group(base_path('routes/team.php'));

// The admin panel: global context, admin role.
Route::middleware(['web', 'auth', 'security', 'noTeam', 'adminScope', 'role:' . config('app.admin_role')])->prefix('admin')->name('admin.')->group(base_path('routes/admin.php'));

Register a new team screen in routes/team.php, which runs inside the workspace group. That group asks for no subscription: each screen asks for its own permission, whatever the team's plan, and a team whose plan ended still reaches its members, roles and files. Requiring a subscription is optional, and it is decided screen by screen by whoever builds it. The plans and billing screens come from the billing package, with their own access check.

How the guard is chosen

Every policy extends App\Policies\BasePolicy, whose before() hook reads the context:

public function before(User $user)
{
    if (getPermissionsTeamId() > 0) {
        $this->defaultGuard = 'team';
    }
}

From then on every check in the policy passes that guard:

$user->hasPermissionTo("retrieve {$this->name}", $this->defaultGuard);

So the same policy class works in the admin panel and inside a team. The methods view, update and delete also call validateTeamOnObject, which compares the record's team_id with the context and refuses a record of another team; with no context, in the admin panel, the check passes.

App\Policies\UserTeamPolicy, the policy of the members screens, adds two things on top: the team's owner passes every one of its checks, judged on the Team passed to the check or, when none is passed, on team(); and create refuses when there is no team or reachedLimit('users') is true, with the flash notice You have reached the maximum number of members allowed by your subscription. Please upgrade your plan to add more members., which carries no link. update and delete also take an optional ?Team, for a check made outside the team's screens. No other policy has an owner shortcut: the owner passes them because the owner role holds every team permission.

When you call Spatie directly instead of a policy, name the guard, because the user's default guard is web:

auth()->user()->hasPermissionTo('edit settings', 'team');
auth()->user()->hasRole('administrator', 'team');

Outside a request

A queued job, an artisan command or a scheduled task has no middleware, so the context is whatever was last set, usually nothing.

A Livewire update is the same case, and it is the one that catches people out. It posts to /livewire/update, which is not a /team route, so the middleware never runs for it and the context is 0. The first render of the page is a normal request and does pass through the middleware, so everything looks right until the component updates.

Every Livewire component that checks a team permission needs to set the context itself, a full-page component as much as a modal. Read it back from the session when the component boots:

class Setting extends Component
{
    public function boot(): void
    {
        $team = session('team_id', 0);
        if (getPermissionsTeamId() !== $team) {
            setPermissionsTeamId($team);
        }
    }
}

The members screens, Members\Index, Members\Create and Members\Edit, take the other route: they keep the team in a public Team $team property filled at mount, #[Locked] on Members\Edit, and call setPermissionsTeamId($this->team->id) in hydrate().

Leave it out and the component loses every team permission from its second render onwards: a button gated by a permission is there when the page loads, disappears the first time the component updates, and comes back only on a reload. Nothing errors, so the only way to catch it is to know the shape of it.

For a job or a command, set it yourself with the team you are working on, and clear the cached relations of any user you loaded before:

setPermissionsTeamId($team->id);
$user->unsetRelation('roles')->unsetRelation('permissions');

App\Traits\UserBase::isAdmin() shows the pattern the kit uses to ask a global question from inside a team: it sets the context to 0, drops the loaded roles, asks whether the person has the global role named in app.admin_role, and restores the team's id afterwards. Nothing is cached: every call asks the database again. App\Traits\HasTeam::hasPermissionInTeam($permission, $teamId) does the same for a permission of another team, and puts the context back when it is done.

Scoping your records

A model that belongs to a team carries a team_id column, filled from team() when the record is created and used to filter every list. Laravel Front resources get both from App\Front\Resources\Team\Resource; see Build a team-scoped CRUD. A Livewire component of your own does the same by hand:

#[Computed]
public function projects()
{
    return Project::where('team_id', team()->id)->latest()->get();
}

Categories already do it: App\Models\Category::getOptions() returns the categories of the current team, and the global ones when there is no team, as in the admin panel.

The team's relations

$user->teams();          // BelongsToMany through user_teams
$user->owner_teams();    // HasMany, the teams the person created
$user->current_team();   // BelongsTo
$team->users();          // BelongsToMany, pivot App\Models\UserTeam
$team->invitations();    // HasMany App\Models\TeamInvitation
$team->roles();          // HasMany App\Models\Role
$team->categories();     // HasMany App\Models\Category
$team->can_access;       // whether the signed-in person is a member
$team->is_owner;         // whether the signed-in person created it
Team::mine();            // the teams the signed-in person belongs to
$team->avatarMedia?->displayUrlFor(48) ?? $team->default_avatar; // the team picture at 48 px

Adding and removing a member is $team->users()->attach($id) and detach($id): the UserTeam pivot has an observer that assigns the default role and sends the notifications, so no service call is needed.

Back