Laravel Jetstream Teams: When Built-In Multi-User Workspaces Save You Weeks
Jetstream's team scaffolding gives Laravel apps multi-user workspaces out of the box. Here's what it generates, how to scope resources correctly, and when to skip it.
06 May 2026, 14:10 UTC

You have a SaaS idea where each customer needs their own workspace: a place to invite colleagues, assign roles, and keep data separated from other customers. Building that from scratch in Laravel means designing team models, invitation flows, pivot tables, and authorization logic. Jetstream ships all of that already wired together. The thesis here is simple: if your app genuinely needs team-based tenancy, Jetstream's scaffolding is a legitimate head start — but only if you understand what it generates and where its opinions will push back.
What Jetstream actually gives you
When you install Jetstream with teams enabled, you get a Team model, a team_user pivot table, a current_team_id column on users, invitation models, and a full UI for creating teams, inviting members by email, and assigning roles. Every user also gets a personal team on registration, which gives you a consistent mental model: resources always belong to a team, never directly to a user.
Roles are defined in app/Providers/JetstreamServiceProvider.php via Jetstream::role() calls. Out of the box you get an admin role with broad permissions and an editor role with a narrower set. These permissions are plain strings you check with Gates or Policies, so they slot into Laravel's normal authorization layer rather than inventing a parallel one.
Installing and verifying the scaffold
Install into a fresh Laravel application (assumes Laravel 10/11-era Jetstream; check composer show laravel/jetstream for your version). Run these from your project root as your normal development user:
composer require laravel/jetstream
php artisan jetstream:install livewire --teams
npm install && npm run build
php artisan migrateThe --teams flag is the important part — without it you get profile and API token management but no team scaffolding. After migrating, verify the schema actually landed: you should see teams, team_user, and team_invitations tables, plus current_team_id on users. Then register a user in the browser, create a second team from the team settings page, and confirm rows appear in teams and team_user with the expected role value on the pivot.
A worked example: scoping resources to the current team
The most common mistake after installing Jetstream is forgetting to scope queries. Jetstream switches $user->currentTeam when a user changes teams, but your models do not automatically filter by it. A practical pattern is a global scope or an explicit relationship through the team:
// app/Models/Project.php
class Project extends Model
{
protected static function booted(): void
{
static::addGlobalScope('team', function (Builder $query) {
if ($user = auth()->user()) {
$query->where('team_id', $user->current_team_id);
}
});
}
}
// Creating a project inside a controller or Livewire component:
$project = $request->user()->currentTeam->projects()->create([
'name' => $validated['name'],
]);For authorization, check the member's team role rather than hand-rolling permission tables. Inside a Policy you can do:
public function update(User $user, Project $project): bool
{
return $user->belongsToTeam($project->team)
&& $user->hasTeamPermission($project->team, 'project:update');
}Then register project:update in the role definitions inside JetstreamServiceProvider. To verify it works, log in as an editor, attempt the action, and confirm a 403; then grant the permission to the editor role and confirm it succeeds. Do not skip the negative test — it is the one that proves the Gate is actually wired in.
Where Jetstream's opinions push back
The scaffolding is deliberately opinionated, and that cuts both ways. Team switching stores state on the user (current_team_id), so background jobs and queued notifications need the team passed explicitly — auth()->user() is null in a queue worker, and the global scope above quietly does nothing there. That is a real footgun: a job that emails "all projects" could leak across teams if you reuse scoped queries without a user context. The fix is to pass team_id into the job and query explicitly.
Customization is the other trade-off. Jetstream publishes controllers and Livewire components you can edit, but heavy edits make future Jetstream upgrades harder to reconcile. If your invitation flow, billing model, or role system diverges significantly from the defaults, budget time for ejecting and owning that code. And if your app is single-user with maybe a shared account later, teams add tables, UI surface, and cognitive overhead you will never use — plain Laravel Breeze is the better starting point in that case.
One more practical note: the Livewire stack uses Blade components throughout. If your frontend is Inertia-only, install the Inertia variant instead of trying to mix the two; running both stacks is possible but rarely worth the maintenance cost.
Making the call
Choose Jetstream teams when multi-user workspaces are a core feature, you are comfortable with Livewire or Inertia, and you can live inside Laravel's conventions. Skip it when tenancy is an afterthought or your domain model needs deeply custom membership logic from day one. Either way, the actionable next step is the same: scaffold a throwaway app with --teams, build one scoped resource like the Project example above, write one passing and one failing authorization test, and see how the opinions feel before committing your real project to them.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.