Scoping Eloquent Models to the Active Jetstream Team
Jetstream ships teams, membership and a currentTeam relationship, but not scoping for your own models. Here is a practical way to enforce team boundaries in queries and authorization, plus where global scopes and API tokens go wrong.
04 Apr 2026, 02:07 UTC

Picture a support ticket: a user belongs to two Jetstream teams, opens a project URL from the wrong browser tab, and sees a record that belongs to the other team. Jetstream did not fail. It gave you the team tables, the membership pivot, and a currentTeam relationship. What it deliberately does not give you is scoping on your own models. That gap is where multi-tenant leaks live.
The thesis here is simple: treat the active team as a request-level constraint enforced in two places, a query scope for reads and an authorization check for writes, rather than relying on one global scope to cover every code path.
What Jetstream actually provisions
Installing with teams enabled (php artisan jetstream:install livewire --teams, or the inertia stack) adds a teams table, a team_user pivot with a role column, and a team_invitations table. The users table gains a current_team_id column, and the User model uses the HasTeams trait.
That trait is the part worth knowing well. It provides $user->currentTeam, $user->allTeams(), $user->ownedTeams, $user->switchTeam($team), $user->belongsToTeam($team), and role helpers such as hasTeamRole() and teamRole(). The creator of a team is recorded as owner through the user_id column on teams plus a role on the pivot. The team model itself is swappable via Jetstream::useTeamModel().
None of that touches a projects table you write yourself. Team isolation is your migration, your query, your check.
Worked example: a team-scoped model
Assume a Project model where every row belongs to exactly one team. Run this from the project root with your normal migration permissions:
php artisan make:migration create_projects_table
Then in the generated migration:
Schema::create('projects', function (Blueprint $table) {
$table->id();
$table->foreignId('team_id')->constrained()->cascadeOnDelete();
$table->string('name');
$table->timestamps();
$table->index(['team_id', 'created_at']);
});
The team_id column is the isolation boundary, and the composite index matters once a team has thousands of rows and every list view filters on it. Check the generated create_teams_table migration in your installed version before writing this: if your team key is not an auto-incrementing integer, use the matching column type and helper (for example foreignUuid('team_id')) instead of foreignId().
On the model, add a local scope rather than a global one:
use App\Models\Team;
class Project extends Model
{
protected $fillable = ['name', 'team_id'];
public function team()
{
return $this->belongsTo(Team::class);
}
public function scopeForTeam($query, $team)
{
return $query->where('team_id', $team->getKey());
}
}
In the controller, resolve the team from the authenticated user and fail loudly when there is none:
public function index(Request $request)
{
$team = $request->user()->currentTeam;
abort_unless($team, 403);
return view('projects.index', [
'projects' => Project::forTeam($team)->latest()->paginate(25),
]);
}
For a single record, do not rely on route model binding alone. Binding resolves the row by primary key before your scope runs, so a member of Team B can still reach /projects/{project} for a Team A record. Add an explicit check:
abort_unless(
$project->team_id === $request->user()->currentTeam?->getKey(),
404
);
Returning 404 rather than 403 avoids confirming that the record exists.
Why a global scope is the tempting wrong default
A global scope that filters every query by currentTeam reads elegantly in a controller and breaks everywhere else. Queued jobs, scheduled commands, and admin tooling run without an authenticated user, so currentTeam is null and the scope silently returns nothing or throws. If you do adopt a global scope, make it opt-outable and require an explicit team context to be set, rather than reading from the session or the request.
The same caution applies to API tokens. Sanctum tokens issued through Jetstream's API token screen are tied to the user, not to a team. If a token must be limited to one team, you need to add that association yourself, for example a nullable team_id on the tokens table plus a check in middleware or a policy, then verify it with a request that attempts the other team's resource.
Trade-offs and things to verify
- Invitation lifetime. Jetstream's
team_invitationsrows carry anexpires_atvalue set when the invitation is created. The default duration and whether it is configurable vary by Jetstream version, so read the generated model and migration in your installed release before promising a specific window to users. - Team key type. Changing the team primary key means editing the
teams,team_user, andteam_invitationsmigrations and every foreign key that references them. Confirm the key type in the generated migration rather than assuming. - Role checks are not row checks.
hasTeamRole()tells you what a user may do inside a team; it says nothing about whether a given row belongs to that team. You need both.
How to confirm it works
- Create two users and two teams, and add the first user to both.
- Create a project in Team A, then switch the UI context to Team B.
- Request the Team A project's URL directly. Expect a 404, not the record.
- Run
php artisan tinkerand confirmProject::forTeam($teamB)->count()excludes that row. - Add a feature test that calls
switchTeam()and asserts the response status, so the boundary is covered by CI rather than by memory.
Jetstream's job is to make teams exist and to track which one is active. Deciding what a team owns, and proving nothing else can reach it, is still application code, and it is worth writing the test before the leak.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.