Architecture Note: Laravel Jetstream Team Management with Role‑Based Access Control
An architecture note on Laravel Jetstream's Team Management: requirements, minimal design using built‑in traits, trust boundaries around the current_team_id session, operational checks, failure modes, and triggers that would push you to a richer permission system or multi‑database tenancy.
10 Aug 2026, 02:36 UTC

Requirements
Applications that serve multiple customers from a single codebase need data isolation and a simple permission model. Jetstream Teams satisfies both by scoping every tenant‑owned model to a team_id and by storing a role (admin / member) on the team_user pivot. The requirement list is short:
- Authenticated users belong to one or more teams.
- Only the current team’s data is visible in a request.
- Two built‑in roles (admin, member) control invitation, member removal, and team deletion.
- No extra packages for the baseline feature set.
Minimal Design
The smallest viable implementation uses only Jetstream’s core classes:
App\Models\Team– extendsJetstream\Teamand usesHasTeamInvitations+BelongsToTeamtraits.team_userpivot table with columnsteam_id,user_id,role.TeamInvitationmodel for pending invites.- Session binding
current_team_idset after login and updated on team switch.
All tenant‑scoped models (e.g. Project, Invoice) add the BelongsToTeam trait, which automatically adds a global scope where('team_id', session('current_team_id')).
// Example: Project model scoped to current team
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Laravel\Jetstream\HasTeamInvitations;
use Laravel\Jetstream\BelongsToTeam;
class Project extends Model
{
use BelongsToTeam; // adds global scope
protected $fillable = ['team_id', 'name'];
}
Trust & Data Boundaries
The server trusts the current_team_id value stored in the authenticated user’s session. The boundary is enforced by:
- Authentication middleware – guarantees a logged‑in user.
- CSRF‑protected POST route
/current-team– updates the session value. - Global scope – every query on a
BelongsToTeammodel receives the team filter automatically.
Client‑side team switching is a simple form POST; no API token carries the team context. For stateless APIs you must pass the team identifier explicitly (e.g. X-Team-ID header) and validate it against the user’s memberships.
Operational Checks
| Check | How to Verify | Frequency |
|---|---|---|
| Pivot integrity | Run a migration‑time assertion: DB::table('team_user')->whereNull('role')->count() must be 0. | On each deploy |
| Invitation health | Monitor team_invitations where accepted_at IS NULL AND created_at < NOW() - INTERVAL 7 DAY. | Daily |
| Membership audit | Attach a model observer on TeamUser that logs created, updated, deleted events to an audit table. | Continuous |
| Scope coverage | Static analysis: grep for models missing BelongsToTeam but having a team_id column. | Per release |
Failure Modes
- Session fixation on team switch – if CSRF validation fails, the
/current-teamrequest is rejected and the session stays on the previous team. Mitigation: ensure the team‑switch form includes@csrfand test withphp artisan test --filter TeamSwitchTest. - Orphaned tenant data –
Team::delete()does not cascade toprojects,invoices, etc. unless foreign keys defineON DELETE CASCADEor you use soft deletes. Plan forSoftDeleteson all tenant models and a scheduled cleanup job. - Privilege escalation via pivot manipulation – a direct DB write changing
roletoadminbypasses policies. Protect by adding a policy gateTeamPolicy::updateMemberRolethat checks the acting user is an admin of the same team.
Design‑Change Triggers
Consider extending the baseline when any of the following appear:
- Granular permissions (e.g. “view‑reports”, “manage‑billing”) – add
spatie/laravel-permissionand map permissions to roles. - Team‑owned resources that must be transferable – introduce a
ownablepolymorphic relation and a transfer workflow. - Database‑per‑tenant isolation – migrate to a multi‑database architecture (e.g.
stancl/tenancy) when regulatory or performance demands exceed shared‑schema limits.
Limitations & Verification
Jetstream’s default roles are hard‑coded strings (admin, member). Changing them requires editing the stub files in vendor/laravel/jetstream/stubs, the migration that creates team_user, and any authorization gates that reference the strings.
Invitation emails rely on the default mail driver; queue failures leave rows in team_invitations with accepted_at = NULL and no automatic retry visibility. Add a failed‑job monitor or a scheduled command that re‑queues stale invitations.
Browser session persistence across team switches depends on Laravel’s session driver (file, database, redis). In a stateless API you must send the team context per request; the session‑based approach will not work.
Verification Steps (run in a fresh project)
composer create-project laravel/laravel demo && cd democomposer require laravel/jetstreamphp artisan jetstream:install livewire --teams(orinertia)php artisan migrate- Inspect created tables:
php artisan db:show --tables=teams,team_user,team_invitations - Run the Jetstream feature tests:
php artisan test --filter TeamTest(publish tests first withphp artisan vendor:publish --tag=jetstream-testsif needed). - Manually switch teams in the UI and verify
session('current_team_id')updates (dump in a route or usephp artisan tinker).
If all tests pass and the session value changes correctly, the minimal design is operational.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.