Loading...

This is taking longer than expected.

Back to the help centre

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
users.current_team_id The team the person last opened. Cleared when they no longer belong to it.
The team_id cookie The same, on the browser, so the switcher can skip itself on the next visit. Forgotten on sign-out.
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   // auth()->user()->current_team, or null

The two middleware

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

  1. Loads the person's current_team, from the cookie when it names a team they belong to, from the column otherwise.
  2. When the team does not exist or the person is not a member, clears current_team_id 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:admin'])->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 adds two things on top: the team's owner passes every check, and create refuses when team()->reachedLimit('users'), with a message that points at the plans page.

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);
        }
    }
}

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\HasTeam::isAdmin shows the pattern the kit uses to ask a global question from inside a team: it sets the context to 0, asks whether the person has the global admin role, and restores the team's id afterwards. Its answer is cached per user for five minutes.

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->canAccess();      // whether the signed-in person is a member
$team->isOwner();        // whether the signed-in person created it
Team::mine();            // the teams the signed-in person belongs to

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.