Scoping Multi-Tenant Logic with Laravel Jetstream Teams
Jetstream's Teams feature gives Laravel apps a session-tracked current team. Here's how to enforce tenant isolation with policies and global scopes — and where the defaults fall short.
08 Nov 2025, 18:12 UTC

Building a multi-tenant application often starts with a daunting question: how do you ensure User A only sees data for Organization X, while still allowing them to join Organization Y? Many developers start by manually adding a team_id column to every table and filtering in each controller. That approach is error-prone, and one missed where clause can leak data between tenants.
Laravel Jetstream's Teams feature offers a cleaner foundation. It combines a pivot-based membership model with a session-tracked "current team" concept, so your application can scope queries and authorization checks to the tenant the user is actively working in. This post walks through how that works, how to enforce it with policies and global scopes, and where the defaults fall short.
How Jetstream Tracks the Active Team
Jetstream links users and teams through a team_user pivot table, which stores the user's role within each team (by default, admin or member). A user can belong to many teams, so Jetstream needs a way to know which one is "active" during a request. It does this with a current_team_id column on the users table, kept in sync with the session when the user switches teams.
The HasTeams trait on your User model exposes this via $user->currentTeam and $user->current_team_id. You can confirm the setup by checking that Features::teams() is enabled in config/jetstream.php and that the HasTeams trait is present on your User model. The practical win: your code never has to pass a team ID through every function call or route parameter, which removes an entire class of tampering bugs where a user edits a URL to reference another tenant's ID.
Enforcing Ownership with Policies
Policies are the right place to protect individual resources. Rather than checking only that a user is logged in, verify that the resource belongs to the user's currently active team:
// app/Policies/ProjectPolicy.php
namespace App\Policies;
use App\Models\Project;
use App\Models\User;
class ProjectPolicy
{
public function update(User $user, Project $project): bool
{
return $project->team_id === $user->current_team_id;
}
public function delete(User $user, Project $project): bool
{
return $project->team_id === $user->current_team_id
&& $user->hasTeamRole($user->currentTeam, 'admin');
}
}With this in place, controllers stay thin: $this->authorize('update', $project); handles the tenant check. The hasTeamRole method comes from Jetstream's HasTeams trait and reads the role from the pivot record, so the delete check restricts destructive actions to team admins without hardcoding user IDs.
Automatic Filtering with a Global Scope
Policies protect single-model actions, but they do nothing for an index query like Project::all(). A global scope closes that gap by filtering every query to the active team:
// app/Models/Project.php
namespace App\Models;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
class Project extends Model
{
protected static function booted(): void
{
static::addGlobalScope('team', function (Builder $builder) {
if (auth()->check() && auth()->user()->current_team_id) {
$builder->where('team_id', auth()->user()->current_team_id);
}
});
}
}Now Project::all() silently returns only the active team's records. Two cautions apply. First, the auth()->check() guard matters: without it, console commands, queued jobs, and seeders (where no user is authenticated) can behave unexpectedly or throw errors. Second, when you genuinely need cross-team data — an admin report, a billing job — bypass the scope explicitly with Project::withoutGlobalScope('team')->get() and treat that call site as a security-sensitive line worth a code review comment.
To verify the scope works, a quick feature test is the most reliable check: create two teams with projects, authenticate as a user with current_team_id set to team one, hit the index endpoint, and assert team two's project never appears in the response.
Where the Defaults Break Down
Jetstream's teams are designed for flat multi-tenancy: users belong to many teams, with a simple role per team. If your domain needs hierarchies — regions containing teams containing users — or more than a couple of roles, you will outgrow the admin/member defaults quickly. Roles are defined in config/jetstream.php (or the Jetstream::role() registrations in a service provider), and adding granular permissions means extending that configuration and writing custom policy logic on top.
Performance is the other pressure point. Authorization checks that touch $user->currentTeam or team roles can trigger repeated queries if the relationship is not eager loaded. On team-heavy pages, eager load the membership and team relationships, and watch query counts with Laravel Debugbar or Telescope before assuming the ORM is doing something sensible.
Finally, be deliberate about coupling. Sprinkling current_team_id checks throughout business logic ties your domain code to Jetstream's implementation. Keeping tenant logic inside policies and global scopes — as shown above — gives you a seam you can replace later if you migrate to a dedicated tenancy package.
A Practical Starting Point
If you are starting a multi-tenant Laravel app today: enable Jetstream teams, add team_id to every tenant-owned table with a foreign key and index, write one policy and one global scope per model, and cover the cross-tenant case with a feature test before shipping. That small amount of structure prevents the most damaging multi-tenant bug — silent data leakage — while keeping the door open to a more sophisticated tenancy model when your requirements genuinely demand it.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.