Scoping SaaS Resources to Teams with Laravel Jetstream
Scope SaaS resources to the active team using Laravel Jetstream teams, model relationships, and authorization policies.
03 Oct 2025, 01:22 UTC

The Problem: One User, Many Teams
In a multi-user SaaS application, a resource such as a project or invoice often belongs to a team rather than a single person. If you query by the authenticated user alone, a member of Team A can accidentally see or edit Team B's data. You need a consistent way to scope every request to the active team.
Laravel Jetstream includes an optional teams module that creates the teams and team_user tables, adds a current team to the session, and provides a team switcher UI. The takeaway is that Jetstream handles the membership plumbing, but you still define which resources belong to a team and enforce access in your controllers and policies.
What the Teams Module Adds
Enable teams in config/jetstream.php by confirming the teams feature is present in the features array. After running migrations, Jetstream creates:
- a teams table for team records
- a team_user pivot table linking users to teams
- a Teamable trait on the User model, exposing currentTeam() and teams()
- a team switcher component for Livewire or Inertia stacks
Jetstream may publish a TeamPolicy during installation, depending on the installer version. After installation, check app/Policies/TeamPolicy.php. If the file does not exist, create your own policy and register it in AuthServiceProvider.
Worked Example: Scoping Projects to a Team
This example assumes Jetstream is installed, the teams feature is enabled, and migrations have run.
1. Create the projects table
Run this migration on your local terminal as a database user with schema modification privileges:
php artisan make:migration create_projects_table
Edit the generated migration file:
Schema::create('projects', function (Blueprint $table) {
$table->id();
$table->foreignId('team_id')->constrained()->cascadeOnDelete();
$table->string('name');
$table->timestamps();
});
Run php artisan migrate. Verify with php artisan tinker:
Schema::hasTable('projects'); // true
2. Define model relationships
On the Project model:
public function team()
{
return $this->belongsTo(Team::class);
}
On the Team model, add the inverse relationship:
public function projects()
{
return $this->hasMany(Project::class);
}
3. Scope creation to the current team
In your ProjectsController:
public function store(Request $request)
{
$team = auth()->user()->currentTeam;
if (!$team) {
return redirect()->route('teams.create')->with('error', 'Create a team first.');
}
$team->projects()->create([
'name' => $request->name,
]);
return redirect()->back();
}
The currentTeam() helper reads the team stored in the session by the team switcher. If no team is selected, it returns null.
4. Enforce authorization
Use Laravel's policy system to block cross-team access. For example, in a controller method:
$this->authorize('update', $team);
Or in a route definition:
Route::put('/teams/{team}', function (Team $team) {
$this->authorize('update', $team);
// ...
})->middleware(['auth', 'can:update,team']);
If TeamPolicy does not exist, register a custom policy in AuthServiceProvider and define update, create, and delete logic that checks the team_user pivot membership.
Limitations to Plan For
- Session-dependent current team. currentTeam() relies on session state. For stateless APIs using Sanctum or Passport, pass team_id in the request header or body and scope queries manually; currentTeam() will not apply.
- Policy scope is not automatic. Jetstream provides membership scaffolding, not resource-level authorization. You must write policies for Project, Invoice, or any other team-owned model.
- Query overhead. Filtering by team_id adds a WHERE clause or join to every resource query. Index team_id on resource tables to keep lookups efficient.
Verification Checklist
- Open config/jetstream.php and confirm teams is listed under features.
- Run php artisan migrate and confirm the teams and team_user tables exist.
- Log in, switch teams using the switcher, and confirm currentTeam() returns the expected team in tinker or a debug endpoint.
- Create a project as Team A, then attempt to access it while switched to Team B; your policy should return a 403 response.
Closing
Jetstream's teams feature removes the repetitive work of building membership tables and a switcher UI. Your remaining job is to attach resources to teams, scope queries to currentTeam(), and back those scopes with policies. Start by enabling the feature, adding team_id to one resource table, and verifying cross-team access is denied.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.