
Chapter 08: Team Management & User Roles
Overview
CRM systems are inherently multi-tenant: different organizations need isolated data. This chapter implements team functionality, allowing users to create teams, invite members, assign roles (owner, admin, member), and switch between teams.
Unlike Jetstream (which provides team management out of the box), the React starter kit requires manual team implementation. This gives you complete control and deeper understanding of multi-tenancy patterns. You'll build team creation, member invitations, role assignment, and team switching from scratch.
By the end of this chapter, your CRM will support multiple teams with role-based permissions. Users can belong to multiple teams, and each team's data will be completely isolated. This prepares you for the authorization chapter, where you'll ensure users can only access their team's data.
This chapter focuses on team CRUD operations and relationships-authorization comes in Chapter 09.
Prerequisites
Before starting this chapter, you should have:
- Completed Chapter 07
- Understanding of many-to-many relationships
- Teams table migrated (from Chapter 05)
- Team model created (from Chapter 06)
Estimated Time: ~90 minutes
What You'll Build
By the end of this chapter, you will have:
- Team creation interface (React component)
- Team invitation system with email invites
- Role assignment (owner, admin, member) stored in pivot table
- Team switching interface in navigation
- Current team stored in session
- Team settings page for managing members
- Member removal functionality
- Understanding of pivot table customization
- Foundation for team-scoped data in next chapter
Team Roles Explained: Ownership vs Membership
Before coding, let's clarify the core team concepts that often confuse developers:
Three Key Relationships
USER ←→ TEAM relationships:
1. TEAM OWNERSHIP
A User can OWN multiple Teams (one-to-many)
Example: John owns "ACME Corp" team and "Tech Startup" team
┌──────────────┐
│ users table │
│ id: 1 │───→ team_id: 1 (John's personal/default team)
│ name: John │
└──────────────┘
But John also OWNS other teams (these are in teams.user_id):
┌──────────────┐
│ teams table │
│ id: 2 │───→ user_id: 1 (John OWNS this team)
│ name: ACME │
└──────────────┘
2. TEAM MEMBERSHIP
A User can BELONG TO multiple Teams (many-to-many via pivot)
Example: Jane belongs to "ACME Corp", "Tech Startup", and "Marketing Team"
┌──────────────────┐
│ team_user pivot │
│ team_id: 2 │───→ Jane is a MEMBER of ACME Corp
│ user_id: 3 │
└──────────────────┘
3. TEAM ROLES (within membership)
Each membership has a ROLE that defines permissions
Roles: owner, admin, member, viewer
┌──────────────────────────┐
│ team_user pivot │
│ team_id: 2 │
│ user_id: 1 │
│ role: "admin" │───→ John is an ADMIN in ACME Corp
│ joined_at: timestamp │
└──────────────────────────┘Real-World Example
Scenario: John works at ACME Corp, Jane is an intern, Bob is a contractor
TEAMS:
┌─────────────────────┐
│ id | name | user_id │
├─────────────────────┤
│ 1 │ John's Personal │ 1 (John owns/created this)
│ 2 │ ACME Corp │ 1 (John owns this team too)
└─────────────────────┘
USER MEMBERSHIPS (team_user pivot table):
┌──────────────────────────────────────────┐
│ team_id | user_id | role │ role meaning
├──────────────────────────────────────────┤
│ 1 │ 1 │ owner │ John owns his personal team
│ 2 │ 1 │ owner │ John owns ACME Corp team
│ 2 │ 2 │ admin │ Jane is an admin (can manage team members)
│ 2 │ 3 │ member │ Bob is a regular member (can view/edit data)
└──────────────────────────────────────────┘
DATABASE STATE:
- Team 1 (Personal): John only, role = owner
- Team 2 (ACME): John (owner), Jane (admin), Bob (member)
WHAT EACH PERSON CAN DO:
- John: Switch to either team, full access to both
- Jane: Switch to ACME only (she only belongs to 1 team), invite/remove members
- Bob: Switch to ACME only, view/edit contacts but can't change team settingsRole Permissions Matrix
| Action | Owner | Admin | Member | Viewer |
|---|---|---|---|---|
| View team data | ✅ | ✅ | ✅ | ✅ |
| Edit team data | ✅ | ✅ | ✅ | ❌ |
| Create records | ✅ | ✅ | ✅ | ❌ |
| Delete records | ✅ | ✅ | ❌ | ❌ |
| Invite members | ✅ | ✅ | ❌ | ❌ |
| Remove members | ✅ | ✅ | ❌ | ❌ |
| Change roles | ✅ | ❌ | ❌ | ❌ |
| Delete team | ✅ | ❌ | ❌ | ❌ |
| Change team settings | ✅ | ❌ | ❌ | ❌ |
Pro Tip: Roles are defined in Chapter 09 (Authorization) using Policies. This chapter focuses on storing roles in the pivot table.
Key Distinction: Ownership vs Membership
OWNERSHIP (users.team_id OR teams.user_id):
- A user "owns" a team they created
- Only 1 user can own a team (initially)
- Ownership might be transferable in future features
MEMBERSHIP (team_user pivot table):
- A user "belongs to" a team
- A user can belong to many teams
- Membership includes a role (owner, admin, member, viewer)
- Every team owner is also a member (with owner role in pivot)
Database Schema Reminder
From Chapter 05, your tables look like:
-- users table
CREATE TABLE users (
id BIGINT PRIMARY KEY,
name VARCHAR,
email VARCHAR UNIQUE,
team_id BIGINT NULLABLE, ← User's "current" team
password VARCHAR,
created_at TIMESTAMP
);
-- teams table
CREATE TABLE teams (
id BIGINT PRIMARY KEY,
user_id BIGINT NOT NULL, ← Team owner (must be NOT NULL)
name VARCHAR,
slug VARCHAR UNIQUE,
created_at TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
-- team_user pivot table (many-to-many)
CREATE TABLE team_user (
id BIGINT PRIMARY KEY,
team_id BIGINT NOT NULL,
user_id BIGINT NOT NULL,
role ENUM('owner','admin','member','viewer') DEFAULT 'member',
joined_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(team_id, user_id),
FOREIGN KEY (team_id) REFERENCES teams(id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);Common Questions Answered
Q: Why does the user have a team_id? A: This stores the user's "current active team" for the session. When Jane logs in, she might be viewing ACME Corp, so team_id=2. If she switches teams, this value updates.
Q: Can a user belong to the same team twice? A: No! The pivot table has UNIQUE(team_id, user_id) which prevents duplicates.
Q: What if a user is invited to a team with multiple roles? A: Not possible with current design. Each user→team pair has exactly one role in the pivot.
Q: Can I change a user's role? A: Yes! In Chapter 09, team owners/admins will be able to update the role column in the pivot table.
Q: What if I delete a user? A: ON DELETE CASCADE means all their team memberships (pivot rows) are deleted automatically. Teams they own are also deleted (owners typically leave teams before deletion).
Objectives
- Create teams via React interface
- Implement user-team many-to-many relationship with roles
- Send invitation emails to new team members
- Handle invitation acceptance flow
- Allow users to switch between their teams
- Display current team in navigation
- Manage team members (view, remove)
- Store role information in pivot table
- Understand session management for current team
- Understand the distinction between team ownership and membership
Quick Start (Optional)
Want to see where this chapter takes you? Here's a 5-minute overview of the end state:
# After completing all steps, you'll be able to:
# 1. Create a team from the UI
curl -X POST http://localhost/teams \
-d '{"name":"My Team"}' \
-H "Content-Type: application/json"
# 2. Invite a user by email
curl -X POST http://localhost/teams/1/invite \
-d '{"email":"user@example.com","role":"member"}' \
-H "Content-Type: application/json"
# 3. Check email in Mailhog (localhost:8025)
# Accept invitation via link
# 4. Switch between teams in UI dropdown
# 5. Manage team members in settings pageKey endpoint structure:
POST /teams— Create teamPOST /teams/{team}/invite— Send invitationGET /invitations/{token}— Show invitation acceptance pagePOST /invitations/{token}/accept— Accept invitationPOST /teams/{team}/switch— Switch active teamGET /teams/{team}/settings— View team settingsDELETE /teams/{team}/members/{user}— Remove member
Step 1: Create Team Model Relationships (~10 min)
Goal
Define comprehensive relationships on the Team model connecting teams to users, and users to teams through the many-to-many pivot table with role tracking.
Actions
- Open the Team model:
code app/Models/Team.php- Update the Team model with relationships:
# filename: app/Models/Team.php
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
class Team extends Model
{
use HasFactory;
protected $fillable = [
'name',
'slug',
'plan_type',
];
/**
* The team owner (creator)
*/
public function owner(): BelongsTo
{
return $this->belongsTo(User::class, 'user_id');
}
/**
* All members of this team
*/
public function members(): BelongsToMany
{
return $this->belongsToMany(User::class)
->withPivot('role')
->withTimestamps();
}
/**
* Check if user is team owner
*/
public function isOwner(User $user): bool
{
return $this->user_id === $user->id;
}
/**
* Check if user has specific role
*/
public function userHasRole(User $user, string $role): bool
{
return $this->members()
->where('user_id', $user->id)
->wherePivot('role', $role)
->exists();
}
/**
* Get user's role on this team
*/
public function getUserRole(User $user): ?string
{
return $this->members()
->where('user_id', $user->id)
->first()
?->pivot
?->role;
}
}- Update the User model to include teams:
code app/Models/User.php- Add the teams relationship to User model:
# filename: app/Models/User.php (add to class)
/**
* All teams this user is a member of
*/
public function teams(): BelongsToMany
{
return $this->belongsToMany(Team::class)
->withPivot('role')
->withTimestamps();
}
/**
* Teams this user owns
*/
public function ownedTeams(): HasMany
{
return $this->hasMany(Team::class);
}
/**
* Current active team
*/
public function currentTeam(): BelongsTo
{
return $this->belongsTo(Team::class, 'current_team_id');
}
/**
* Get the user's role on a specific team
*/
public function getRoleOnTeam(Team $team): ?string
{
return $this->teams()
->where('team_id', $team->id)
->first()
?->pivot
?->role;
}
/**
* Check if user is owner of team
*/
public function isTeamOwner(Team $team): bool
{
return $this->id === $team->user_id;
}
/**
* Check if user has role on team
*/
public function hasTeamRole(Team $team, string $role): bool
{
return $this->getRoleOnTeam($team) === $role;
}- Verify imports in User model:
# Add these imports at the top if missing
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\BelongsTo;Expected Result
✓ Team model has owner(), members() relationships
✓ User model has teams(), ownedTeams(), currentTeam() relationships
✓ Role is stored in pivot table and accessible via ->pivot->role
✓ Helper methods (isOwner, userHasRole, getRoleOnTeam) are available
✓ No errors when running: sail tinkerWhy It Works
The pivot table team_user stores the many-to-many relationship with the role column:
Relationship Design:
withPivot('role')— Exposes pivot column so we can access role data via$user->pivot->rolewithTimestamps()— Tracks when users joined teams (created_at/updated_at)- Helper methods — Encapsulate business logic for easy testing and reuse
Multi-Team Support:
This pattern allows a user to belong to multiple teams with different roles:
- Owner of "Acme Corp" (via pivot role='owner')
- Admin of "Consulting Inc" (via pivot role='admin')
- Member of "Project X" (via pivot role='member')
Each relationship is independent—removing a user from one team doesn't affect their membership in others.
Why Separate Ownership from Membership?
We track TWO relationships:
ownedTeams()viauser_idonteamstable — Permanent owner relationshipteams()viateam_userpivot table — Flexible membership with roles
This allows:
- Team owner never loses ownership (immutable
user_id) - Members can have different roles (flexible via pivot)
- Admins can remove members without affecting ownership
- Authorization can check: "Is user the owner?" vs "Does user have role?"
Troubleshooting
- Error: "Column 'role' doesn't exist in pivot table" — The
team_usermigration hasn't run yet. Runsail artisan migrateto create the pivot table with the role column. - Relationship not found — Ensure you imported the Team model in User model:
use App\Models\Team; - Pivot data not loading — Remember to call
->pivot->rolenot just->role. ThewithPivot()method explicitly exposes pivot columns.
Step 2: Build Team Creation Interface (~20 min)
Goal
Create a React component for team creation and a Laravel controller to handle team creation, linking the owner and storing the initial membership.
Actions
- Create the Team Creation React component:
code resources/js/Pages/Teams/CreateTeamModal.tsx- Implement the component:
# filename: resources/js/Pages/Teams/CreateTeamModal.tsx
import { FormEventHandler, useState } from 'react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog'
import { useToast } from '@/hooks/use-toast'
import { router } from '@inertiajs/react'
export default function CreateTeamModal() {
const [open, setOpen] = useState(false)
const [isLoading, setIsLoading] = useState(false)
const [teamName, setTeamName] = useState('')
const { toast } = useToast()
const handleSubmit: FormEventHandler = async (e) => {
e.preventDefault()
setIsLoading(true)
try {
router.post('/teams', { name: teamName }, {
onSuccess: () => {
toast({
title: 'Success',
description: `Team "${teamName}" created successfully!`,
})
setTeamName('')
setOpen(false)
},
onError: (errors) => {
toast({
title: 'Error',
description: errors.name || 'Failed to create team',
variant: 'destructive',
})
},
})
} finally {
setIsLoading(false)
}
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="outline">+ New Team</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Create New Team</DialogTitle>
<DialogDescription>
Create a team to organize your CRM data and invite team members.
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label htmlFor="team-name" className="text-sm font-medium">
Team Name
</label>
<Input
id="team-name"
placeholder="My Company"
value={teamName}
onChange={(e) => setTeamName(e.target.value)}
disabled={isLoading}
required
/>
</div>
<div className="flex justify-end gap-2">
<Button
type="button"
variant="outline"
onClick={() => setOpen(false)}
disabled={isLoading}
>
Cancel
</Button>
<Button type="submit" disabled={isLoading}>
{isLoading ? 'Creating...' : 'Create Team'}
</Button>
</div>
</form>
</DialogContent>
</Dialog>
)
}- Create the Team Controller:
sail artisan make:controller TeamController --model=Team- Implement the store method:
# filename: app/Http/Controllers/TeamController.php
<?php
namespace App\Http\Controllers;
use App\Models\Team;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
class TeamController extends Controller
{
/**
* Display teams for authenticated user
*/
public function index()
{
$teams = auth()->user()->teams()->get();
return inertia('Teams/Index', ['teams' => $teams]);
}
/**
* Create new team
*/
public function store(Request $request)
{
$validated = $request->validate([
'name' => 'required|string|max:255|unique:teams',
]);
// Create team with authenticated user as owner
$team = auth()->user()->ownedTeams()->create([
'name' => $validated['name'],
'slug' => Str::slug($validated['name']) . '-' . Str::random(8),
]);
// Add owner as member with owner role
$team->members()->attach(auth()->id(), ['role' => 'owner']);
// Set as current team
auth()->user()->update(['current_team_id' => $team->id]);
return redirect('/dashboard')->with('status', 'Team created successfully!');
}
/**
* Switch current team
*/
public function switchTeam(Team $team)
{
// Verify user belongs to team
$this->authorize('switch', $team);
auth()->user()->update(['current_team_id' => $team->id]);
return redirect('/dashboard')->with('status', 'Team switched successfully!');
}
}- Add routes:
# filename: routes/web.php (add to authenticated routes group)
Route::middleware(['auth', 'verified'])->group(function () {
Route::post('/teams', [TeamController::class, 'store']);
Route::post('/teams/{team}/switch', [TeamController::class, 'switchTeam']);
Route::get('/teams', [TeamController::class, 'index']);
// ... existing routes
});- Test team creation:
# In tinker:
sail tinker
> $user = User::first()
> $team = $user->ownedTeams()->create(['name' => 'Test Team', 'slug' => 'test-team'])
> $team->members()->attach($user->id, ['role' => 'owner'])
> $user->update(['current_team_id' => $team->id])
> exitExpected Result
✓ Team creation form appears in UI
✓ Submitting form creates team in database
✓ User is automatically added as owner
✓ current_team_id is set on user
✓ Team appears in team switcher
✓ Redirect to dashboard after creationWhy It Works
- Slug generation — Uses Laravel's
Str::slug()+ random string for unique, URL-friendly identifiers - Owner relationship —
auth()->user()->ownedTeams()->create()sets user_id automatically - Pivot entry —
->members()->attach()creates the team_user pivot record with role - Current team — Stored in
current_team_idon users table for quick access in session
Troubleshooting
- Error: "unique constraint violation" — Team name is duplicated. Validation should prevent this; check validation is running.
- User not added to members — Forgot to call
->members()->attach(). This must be done after team creation. - current_team_id not updating — Verify the users table migration includes the column from Chapter 07.
Step 3: Implement Team Invitations (~25 min)
Goal
Create an invitation system that sends emails to new team members, allows them to accept invitations, and adds them to the team with appropriate roles.
Invitation Flow
Understanding the invitation lifecycle helps clarify the implementation:
This ensures:
- Invalid emails are rejected immediately
- Tokens are secure (32-char random strings)
- Invitations can be tracked (pending/accepted/rejected)
- Users are protected (must be logged in to accept)
Actions
- Create Invitation model and migration:
sail artisan make:model Invitation -m- Update the Invitation migration:
# filename: database/migrations/XXXX_XX_XX_XXXXXX_create_invitations_table.php
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('invitations', function (Blueprint $table) {
$table->id();
$table->foreignId('team_id')->constrained()->cascadeOnDelete();
$table->string('email');
$table->string('token')->unique();
$table->string('role')->default('member');
$table->timestamp('accepted_at')->nullable();
$table->timestamp('rejected_at')->nullable();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('invitations');
}
};- Implement the Invitation model:
# filename: app/Models/Invitation.php
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class Invitation extends Model
{
use HasFactory;
protected $fillable = ['email', 'token', 'role', 'team_id'];
protected $casts = [
'accepted_at' => 'datetime',
'rejected_at' => 'datetime',
];
public function team(): BelongsTo
{
return $this->belongsTo(Team::class);
}
public function isAccepted(): bool
{
return !is_null($this->accepted_at);
}
public function isRejected(): bool
{
return !is_null($this->rejected_at);
}
public function isPending(): bool
{
return is_null($this->accepted_at) && is_null($this->rejected_at);
}
}- Create TeamInvitation mailable:
sail artisan make:mail TeamInvitation- Implement the mailable:
# filename: app/Mail/TeamInvitation.php
<?php
namespace App\Mail;
use App\Models\Invitation;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Queue\SerializesModels;
class TeamInvitation extends Mailable
{
use Queueable, SerializesModels;
public function __construct(
public Invitation $invitation
) {}
public function envelope()
{
return new Envelope(
subject: "You're invited to {$this->invitation->team->name}",
);
}
public function content()
{
return new Content(
view: 'emails.team-invitation',
);
}
public function attachments()
{
return [];
}
}- Create invitation email view:
mkdir -p resources/views/emails
code resources/views/emails/team-invitation.blade.php- Implement email template:
# filename: resources/views/emails/team-invitation.blade.php
<x-mail::message>
# You're Invited!
You've been invited to join **{{ $invitation->team->name }}** on our CRM.
<x-mail::button :url="route('invitations.accept', $invitation->token)">
Accept Invitation
</x-mail::button>
Or copy and paste this link in your browser:
{{ route('invitations.accept', $invitation->token) }}
**Role:** {{ ucfirst($invitation->role) }}
Thanks,<br>
{{ config('app.name') }}
</x-mail::message>- Add invitation routes and controller methods:
# filename: routes/web.php
// Invitation routes (public - no auth needed yet)
Route::get('/invitations/{token}', [InvitationController::class, 'show'])->name('invitations.show');
Route::post('/invitations/{token}/accept', [InvitationController::class, 'accept'])->name('invitations.accept');
Route::middleware(['auth', 'verified'])->group(function () {
Route::post('/teams/{team}/invite', [TeamController::class, 'invite'])->name('teams.invite');
// ... rest of routes
});- Add invite method to TeamController:
🚨 Authorization Note: The
$this->authorize('invite', $team)call will require a policy method in Chapter 09. For now, this will throw an exception until you create aTeamPolicywith aninvitemethod.
# filename: app/Http/Controllers/TeamController.php
use App\Mail\TeamInvitation;
use App\Models\Invitation;
use Mail;
public function invite(Request $request, Team $team)
{
// 🚨 Chapter 09: This will use a policy method called invite($user, $team)
$this->authorize('invite', $team);
$validated = $request->validate([
'email' => 'required|email',
'role' => 'required|in:member,admin',
]);
// Create invitation record
$invitation = Invitation::create([
'team_id' => $team->id,
'email' => $validated['email'],
'role' => $validated['role'],
'token' => \Illuminate\Support\Str::random(32),
]);
// Send invitation email
Mail::to($validated['email'])->send(new TeamInvitation($invitation));
return back()->with('status', 'Invitation sent!');
}- Create InvitationController:
sail artisan make:controller InvitationController- Implement accept invitation:
# filename: app/Http/Controllers/InvitationController.php
<?php
namespace App\Http\Controllers;
use App\Models\Invitation;
use App\Models\User;
class InvitationController extends Controller
{
public function show($token)
{
$invitation = Invitation::where('token', $token)->firstOrFail();
if (!$invitation->isPending()) {
return redirect('/login')->with('error', 'Invitation already processed.');
}
return inertia('Invitations/Accept', ['invitation' => $invitation]);
}
public function accept($token)
{
$invitation = Invitation::where('token', $token)->firstOrFail();
if (!$invitation->isPending()) {
return back()->with('error', 'Invitation already processed.');
}
$user = auth()->user();
// Add user to team
if (!$user->teams()->where('team_id', $invitation->team_id)->exists()) {
$user->teams()->attach(
$invitation->team_id,
['role' => $invitation->role]
);
}
// Mark invitation as accepted
$invitation->update(['accepted_at' => now()]);
return redirect('/dashboard')->with('status', 'You joined the team!');
}
}Expected Result
✓ Invitations are created with unique tokens
✓ Email is sent when inviting users
✓ Users can accept invitations via link
✓ Accepting adds user to team with specified role
✓ Pending/Accepted/Rejected states tracked
✓ No errors in mail logs (check Mailhog at localhost:8025)Why It Works
- Token generation — Random 32-character string provides security without database queries
- Stateful tracking — accepted_at/rejected_at fields track invitation lifecycle
- Role preservation — Role is stored in invitation so it's captured at send time, not acceptance time
- Email delivery — Laravel Mail handles queue/delivery with Mailhog for testing
Troubleshooting
- Email not sending — Verify Mailhog is running:
sail ps. Checkconfig/mail.phphasMAIL_DRIVER=logfor testing. - Token not found — Invitations might have expired. Generate new one.
- User already in team — Acceptance prevents duplicate entries by checking before attaching.
Step 4: Add Team Switching (~15 min)
Goal
Allow users to switch between their teams, storing the current team in the session and updating the navigation to show the active team.
Actions
- Update Team Switcher component:
# filename: resources/js/Components/TeamSwitcher.tsx
import { useEffect, useState } from 'react'
import { User } from '@/types'
import { Team } from '@/types'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { Button } from '@/components/ui/button'
import { router } from '@inertiajs/react'
interface Props {
user: User
teams: Team[]
}
export default function TeamSwitcher({ user, teams }: Props) {
const [currentTeam, setCurrentTeam] = useState<Team | null>(null)
useEffect(() => {
if (user.current_team_id) {
setCurrentTeam(
teams.find((t) => t.id === user.current_team_id) || null
)
}
}, [user.current_team_id, teams])
const switchTeam = (team: Team) => {
router.post(`/teams/${team.id}/switch`, {}, {
onSuccess: () => {
setCurrentTeam(team)
},
})
}
if (!currentTeam) return null
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="w-full justify-start">
<span className="truncate">{currentTeam.name}</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-56">
<div className="px-2 py-1.5 text-sm font-medium text-muted-foreground">
Switch Team
</div>
{teams.map((team) => (
<DropdownMenuItem
key={team.id}
onClick={() => switchTeam(team)}
className={currentTeam.id === team.id ? 'bg-accent' : ''}
>
{team.name}
{currentTeam.id === team.id && <span className="ml-auto">✓</span>}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)
}- Create TeamSwitcher middleware (optional, for automatic team scoping):
sail artisan make:middleware EnsureTeamIsSelected- Implement middleware:
# filename: app/Http/Middleware/EnsureTeamIsSelected.php
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
class EnsureTeamIsSelected extends Closure
{
public function handle(Request $request, Closure $next)
{
if (!auth()->check()) {
return $next($request);
}
$user = auth()->user();
// If user has no current team, set to first team
if (!$user->current_team_id) {
$firstTeam = $user->teams()->first();
if ($firstTeam) {
$user->update(['current_team_id' => $firstTeam->id]);
}
}
return $next($request);
}
}- Register middleware in
app/Http/Kernel.php:
Open app/Http/Kernel.php and add the middleware to the global middleware stack:
# filename: app/Http/Kernel.php
class Kernel extends HttpKernel
{
/**
* The application's global HTTP middleware stack.
*/
protected $middleware = [
// ... existing middleware (EncryptCookies, etc.) ...
\App\Http\Middleware\EnsureTeamIsSelected::class,
];
// ... rest of Kernel class ...
}This middleware runs on EVERY request (authenticated or not), ensuring users always have a team context when needed.
- Update Dashboard to show team and teams list:
code resources/js/Pages/Dashboard.tsx- Display teams in dashboard:
# filename: resources/js/Pages/Dashboard.tsx
import { Team, User } from '@/types'
import TeamSwitcher from '@/Components/TeamSwitcher'
import CreateTeamModal from '@/Pages/Teams/CreateTeamModal'
interface Props {
user: User
teams: Team[]
}
export default function Dashboard({ user, teams }: Props) {
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h1>Dashboard</h1>
<CreateTeamModal />
</div>
<div className="rounded-lg border p-4">
<h2 className="mb-4 text-lg font-semibold">Your Teams</h2>
<TeamSwitcher user={user} teams={teams} />
</div>
{/* Rest of dashboard content */}
</div>
)
}Expected Result
✓ Team switcher appears in navigation
✓ Dropdown shows all user's teams
✓ Clicking team updates current_team_id
✓ Navigation persists selected team
✓ Subsequent requests are team-scoped
✓ New users default to first teamWhy It Works
- Middleware — Ensures every authenticated user has a current_team_id
- Session storage — User's current_team_id is persisted in database, not volatile session
- Dropdown UI — Shows all teams with checkmark on active team
- Route parameter — Controllers can access team via route parameter or user's current team
Troubleshooting
- Middleware not running — Verify it's registered in Kernel and applied to routes
- Current team not persisting — Check that POST to
/teams/{team}/switchupdates the database - Teams not showing — Ensure user is attached to teams via members pivot table
Step 5: Create Team Settings Page (~20 min)
Goal
Build a team management interface where team owners can invite members, view current members, change their roles, and remove them from the team.
Actions
- Create Team Settings React page:
mkdir -p resources/js/Pages/Teams
code resources/js/Pages/Teams/Settings.tsx- Implement Team Settings page:
# filename: resources/js/Pages/Teams/Settings.tsx
import { FormEventHandler, useState } from 'react'
import { Team, User } from '@/types'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { useToast } from '@/hooks/use-toast'
import { router } from '@inertiajs/react'
interface TeamMember {
id: number
name: string
email: string
pivot: {
role: string
}
}
interface Props {
auth: { user: User }
team: Team & { members: TeamMember[] }
}
export default function TeamSettings({ auth, team }: Props) {
const [inviteEmail, setInviteEmail] = useState('')
const [inviteRole, setInviteRole] = useState('member')
const [isInviting, setIsInviting] = useState(false)
const { toast } = useToast()
const handleInvite: FormEventHandler = async (e) => {
e.preventDefault()
setIsInviting(true)
router.post(
`/teams/${team.id}/invite`,
{ email: inviteEmail, role: inviteRole },
{
onSuccess: () => {
toast({
title: 'Success',
description: `Invitation sent to ${inviteEmail}`,
})
setInviteEmail('')
setInviteRole('member')
},
onError: (errors) => {
toast({
title: 'Error',
description: errors.email || 'Failed to send invitation',
variant: 'destructive',
})
},
onFinish: () => setIsInviting(false),
}
)
}
const handleRemoveMember = (userId: number) => {
if (confirm('Remove this member from the team?')) {
router.delete(`/teams/${team.id}/members/${userId}`, {
onSuccess: () => {
toast({
title: 'Success',
description: 'Member removed from team',
})
},
onError: () => {
toast({
title: 'Error',
description: 'Failed to remove member',
variant: 'destructive',
})
},
})
}
}
const isOwner = auth.user.id === team.user_id
return (
<div className="space-y-6">
<h1 className="text-3xl font-bold">{team.name} Settings</h1>
{/* Invite Section */}
{isOwner && (
<div className="rounded-lg border p-6">
<h2 className="mb-4 text-xl font-semibold">Invite Team Member</h2>
<form onSubmit={handleInvite} className="space-y-4">
<div className="grid gap-4 md:grid-cols-3">
<Input
type="email"
placeholder="person@example.com"
value={inviteEmail}
onChange={(e) => setInviteEmail(e.target.value)}
disabled={isInviting}
required
/>
<Select value={inviteRole} onValueChange={setInviteRole}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="member">Member</SelectItem>
<SelectItem value="admin">Admin</SelectItem>
</SelectContent>
</Select>
<Button type="submit" disabled={isInviting}>
{isInviting ? 'Sending...' : 'Send Invite'}
</Button>
</div>
</form>
</div>
)}
{/* Members List */}
<div className="rounded-lg border">
<div className="border-b p-6">
<h2 className="text-xl font-semibold">Team Members</h2>
</div>
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Email</TableHead>
<TableHead>Role</TableHead>
{isOwner && <TableHead>Actions</TableHead>}
</TableRow>
</TableHeader>
<TableBody>
{team.members.map((member) => (
<TableRow key={member.id}>
<TableCell>{member.name}</TableCell>
<TableCell>{member.email}</TableCell>
<TableCell className="capitalize">{member.pivot.role}</TableCell>
{isOwner && member.id !== team.user_id && (
<TableCell>
<Button
variant="destructive"
size="sm"
onClick={() => handleRemoveMember(member.id)}
>
Remove
</Button>
</TableCell>
)}
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
)
}- Add team settings route to TeamController:
🚨 Authorization Note: The
authorize()calls reference policy methods that will be implemented in Chapter 09.
# filename: app/Http/Controllers/TeamController.php
public function settings(Team $team)
{
// 🚨 Chapter 09: This uses policy method update($user, $team)
$this->authorize('update', $team);
return inertia('Teams/Settings', [
'team' => $team->load('members'),
]);
}
public function removeMember(Team $team, User $member)
{
$this->authorize('manageMember', $team);
$team->members()->detach($member->id);
return back()->with('status', 'Member removed from team!');
}- Add routes:
# filename: routes/web.php
Route::middleware(['auth', 'verified'])->group(function () {
Route::get('/teams/{team}/settings', [TeamController::class, 'settings'])->name('teams.settings');
Route::delete('/teams/{team}/members/{member}', [TeamController::class, 'removeMember'])->name('teams.members.remove');
});- Test the settings page:
# Navigate to http://localhost/teams/1/settings
# Try inviting a team member, viewing members, and removing themExpected Result
✓ Settings page loads with team name
✓ Invite form accepts email and role
✓ Members table shows all team members
✓ Owner can see Remove buttons
✓ Non-owners cannot see Invite form
✓ Removing member deletes from team_user pivot
✓ Removed members can no longer access teamWhy It Works
- Authorization —
authorize()ensures only owners can manage teams - Eager loading —
->load('members')fetches members in one query - Pivot access —
member.pivot.roleaccesses role data through relationship - Cascading operations — Detaching from pivot table removes all access
Troubleshooting
- Invite form not visible — User might not be team owner. Check
isOwnercalculation. - Member list empty — Run
sail tinkerto verify members are attached to team. - Remove button not working — Check route is registered and authorization passes.
Exercises
Exercise 1: Create Your First Team
Goal: Test team creation flow end-to-end
Create a team named "My Company" and verify it's set up correctly.
Requirements:
- [ ] Navigate to dashboard
- [ ] Click "+ New Team" button
- [ ] Enter team name "My Company"
- [ ] Submit form
- [ ] See success message
Validation:
# In Tinker, verify the team was created correctly:
sail tinker
> $team = Team::where('name', 'My Company')->first()
> $team->user_id === auth()->id() # Should be true (you're owner)
> $team->members()->where('user_id', auth()->id())->exists() # Should be true
> auth()->user()->current_team_id === $team->id # Should be true
> exitExpected output: All three checks return true
Exercise 2: Invite a Team Member
Goal: Test complete invitation flow with email acceptance
Invite another user and verify the entire system works.
Requirements:
- [ ] Go to team settings page
- [ ] Enter email "test@example.com" in invite form
- [ ] Select role: "member"
- [ ] Click "Send Invite"
- [ ] Check Mailhog at
localhost:8025 - [ ] Open email from noreply@localhost
- [ ] Click "Accept Invitation" button
- [ ] Confirm you've joined the team
Validation:
# Verify invitation was accepted:
sail tinker
> $invitation = Invitation::where('email', 'test@example.com')->first()
> $invitation->isPending() # Should be false (already accepted)
> $invitation->accepted_at # Should show timestamp
> exitExpected output: false for isPending, and a timestamp for accepted_at
Exercise 3: Test Helper Methods
Goal: Verify all model helper methods work correctly
Test the helper methods you've added to User and Team models.
Requirements:
- [ ] Open Tinker
- [ ] Retrieve a user and team
- [ ] Test each helper method
- [ ] Verify correct return types
Validation:
sail tinker
# Get a user and team
> $user = User::first()
> $team = Team::first()
# Test User model helpers
> $user->isTeamOwner($team) # Returns: true or false
> $user->getRoleOnTeam($team) # Returns: 'owner', 'admin', 'member', or null
> $user->hasTeamRole($team, 'owner') # Returns: true or false
# Test Team model helpers
> $team->isOwner($user) # Returns: true or false
> $team->userHasRole($user, 'owner') # Returns: true or false
> $team->getUserRole($user) # Returns: role string or null
> exitExpected output: All helpers return appropriate values (booleans or strings) without errors
Multi-Tenancy Data Isolation: How It Works
After implementing teams, you might wonder: How does the CRM ensure Team A can't see Team B's data? This is multi-tenancy in action. Let's visualize it:
Data Isolation Pattern
┌─────────────────────────────────────────────────────────────────────────┐
│ MULTI-TENANT DATA ISOLATION │
└─────────────────────────────────────────────────────────────────────────┘
DATABASE (Single instance, multiple tenants):
┌──────────────────────────────────────────────────────────────────────┐
│ teams table │
├────┬──────────────┬─────────┐ │
│ id │ name │ user_id │ │
├────┼──────────────┼─────────┤ │
│ 1 │ ACME Corp │ 1 │ ← John's team │
│ 2 │ Tech Startup │ 3 │ ← Bob's team │
│ 3 │ Marketing Co │ 5 │ ← Alice's team │
└──────────────────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────────────────┐
│ contacts table (SCOPED BY team_id) │
├────┬──────┬─────────┬──────────┐ │
│ id │ name │ team_id │ company_id │ │
├────┼──────┼─────────┼──────────┤ │
│ 1 │ John │ 1 │ 10 │ ← ACME's contacts │
│ 2 │ Jane │ 1 │ 10 │ │
│ 3 │ Bob │ 2 │ 20 │ ← Tech Startup's contacts │
│ 4 │ Mary │ 2 │ 20 │ │
│ 5 │ Carl │ 3 │ 30 │ ← Marketing Co's contacts │
└──────────────────────────────────────────────────────────────────────┘
SAME PATTERN FOR:
- companies, deals, tasks, etc.
- EVERY row has team_id to identify its tenant
DATABASE CONSTRAINTS:
┌──────────────────────────────────────────────────────────────────────┐
│ All tables with team_id have: │
│ - Foreign key: REFERENCES teams(id) ON DELETE CASCADE │
│ - Index: CREATE INDEX idx_team_id ON contacts(team_id) │
│ - Every query filters by team_id automatically │
└──────────────────────────────────────────────────────────────────────┘How Queries Are Scoped
Without multi-tenancy protection:
// ❌ BAD: Returns contacts from ALL teams!
$contacts = Contact::all(); // Could see confidential data from other teamsWith multi-tenancy (Laravel's global scope):
// ✅ GOOD: Automatically scopes to current team
$contacts = Contact::all(); // Only ACME Corp's contacts (team_id = 1)
// Behind the scenes:
// SELECT * FROM contacts WHERE team_id = 1 ← Global scope applied!Relationship Hierarchy
┌────────────────────────────────────────────────────────────┐
│ TENANT ISOLATION │
└────────────────────────────────────────────────────────────┘
JOHN's data (Team 1):
┌─────────────────────┐
│ Team 1: ACME Corp │
│ user_id: 1 (John) │
├─────────────────────┤
│ Contacts (5) │
│ - All have team_id=1│
│ Companies (3) │
│ - All have team_id=1│
│ Deals (12) │
│ - All have team_id=1│
└─────────────────────┘
↑ Only John can access (and Jane/Bob if added as members)
BOB's data (Team 2):
┌─────────────────────┐
│ Team 2: Tech Start │
│ user_id: 3 (Bob) │
├─────────────────────┤
│ Contacts (8) │
│ - All have team_id=2│
│ Companies (4) │
│ - All have team_id=2│
│ Deals (20) │
│ - All have team_id=2│
└─────────────────────┘
↑ Only Bob can access (isolated from Team 1)
CRITICAL GUARANTEE:
- There is NO WAY for Bob to query Team 1's data
- Even if Bob tries: Contact::whereTeamId(1)->get()
- Laravel's global scope prevents this AUTOMATICALLYQuery Execution Flow
User: John (Team 1)
Request: GET /api/contacts
↓
app/Http/Controllers/ContactController.php:
public function index() {
return Contact::all();
}
↓
Laravel's Global Scope (auto-applied):
→ Adds WHERE clause: team_id = 1
→ Query becomes: SELECT * FROM contacts WHERE team_id = 1
↓
Database returns:
- John's contacts only
- Bob cannot see this query or results
- Data is ISOLATED at database levelWhy This Matters for Security
| Scenario | Without Multi-Tenancy | With Multi-Tenancy |
|---|---|---|
| Bob tries to access Team 1 data | ❌ Possible - data leak! | ✅ Blocked - global scope filters |
| Query doesn't specify team_id | ❌ Returns all teams' data | ✅ Only current team data |
| Database compromised | ❌ All data exposed | ⚠️ Still exposed (separate backups help) |
| Accidental query | ❌ Can leak between teams | ✅ Automatic filtering prevents |
| URL manipulation (/contacts/1) | ❌ Might access other teams' contacts | ✅ Policy checks team ownership (Chapter 09) |
Implementation Requirements
For multi-tenancy to work:
// 1. Model must have global scope
class Contact extends Model {
protected static function boot() {
parent::boot();
static::addGlobalScope('team', function (Builder $builder) {
$builder->where('team_id', auth()->user()->team_id);
});
}
}
// 2. Create queries must include team_id
Contact::create([
'name' => 'Jane Doe',
'team_id' => auth()->user()->team_id, // NEVER trust user input
'email' => 'jane@example.com',
]);
// 3. Modify queries must verify ownership
$contact = Contact::findOrFail($id); // Already scoped to current team
$contact->update(['name' => 'New Name']); // Safe!
// 4. Delete queries must verify authorization
$contact->delete(); // Only current team's contacts accessibleTesting Multi-Tenancy
How to verify data is truly isolated:
sail artisan tinker
# Create two teams
>>> $team1 = Team::create(['name' => 'Team A', 'user_id' => 1]);
>>> $team2 = Team::create(['name' => 'Team B', 'user_id' => 2]);
# Add users to teams
>>> $team1->users()->attach(1, ['role' => 'owner']);
>>> $team2->users()->attach(2, ['role' => 'owner']);
# Create contacts in each team
>>> Contact::create(['name' => 'Alice', 'team_id' => 1]);
>>> Contact::create(['name' => 'Bob', 'team_id' => 2]);
# Simulate User 1 accessing contacts
>>> auth()->loginUsingId(1); # Simulate logging in as user 1
>>> auth()->user()->team_id = 1;
>>> Contact::all(); # Should show only Alice
# Result: Collection with 1 item (Alice)
# Simulate User 2 accessing contacts
>>> auth()->loginUsingId(2); # Simulate logging in as user 2
>>> auth()->user()->team_id = 2;
>>> Contact::all(); # Should show only Bob
# Result: Collection with 1 item (Bob)
# ✅ Isolation confirmed!Troubleshooting
Common Team-Related Errors
Error: "Team relationship not found"
Symptom: Call to undefined method team() on Invitation or similar relationship error
Cause: Relationship method not defined on model or typo in method name
Solution:
- Check Invitation model has
public function team()defined - Verify it returns
BelongsTo(Team::class) - Check spelling:
teamnotteams
// ✓ Correct
public function team(): BelongsTo
{
return $this->belongsTo(Team::class);
}
// ❌ Wrong
public function team()
{
return $this->Team(); // Wrong - class name as method
}Error: "User already belongs to team"
Symptom: SQLSTATE[23000]: Integrity constraint violation when accepting invitation
Cause: User being attached to team they're already a member of
Solution: The InvitationController already checks this:
// Check user isn't already a member
if (!$user->teams()->where('team_id', $invitation->team_id)->exists()) {
$user->teams()->attach(
$invitation->team_id,
['role' => $invitation->role]
);
}If you still get this error, verify the check is in place and matches your exact column names.
Error: "Role column doesn't exist in pivot"
Symptom: SQLSTATE[42S22]: Column not found: 1054 Unknown column 'team_user.role'
Cause: Migration hasn't been run or role column isn't in team_user table
Solution:
# Run migrations
sail artisan migrate
# Verify table structure
sail artisan tinker
> Schema::getColumns('team_user')
> exitIf role column is missing, create a new migration:
sail artisan make:migration add_role_to_team_user_tableThen add:
public function up(): void
{
Schema::table('team_user', function (Blueprint $table) {
$table->string('role')->default('member')->after('user_id');
});
}Error: "Email not sending (Mailhog)"
Symptom: Invitation sent but email doesn't appear in Mailhog at localhost:8025
Cause: Mail configuration or Mailhog container not running
Solution:
# Check containers are running
sail ps
# Should show mailhog service running
# Verify mail config
cat config/mail.php
# Should have MAIL_DRIVER=log or MAIL_HOST=mailhog
# Check Mailhog is accessible
curl http://localhost:8025/api/v1/messages
# If Mailhog is down, restart it
sail restart mailhogError: "Authorization policy not found"
Symptom: PolicyNotFound when trying to authorize actions
Cause: Policy class hasn't been created or isn't registered
Solution:
- Create policy files (we'll do this in Chapter 09, but note it here for preparation):
sail artisan make:policy TeamPolicy --model=Team- Register in
app/Providers/AuthServiceProvider.php:
protected $policies = [
Team::class => TeamPolicy::class,
];Team Scoping Issues
Problem: User can see other teams' data
Symptom: In queries, seeing data from teams the user doesn't belong to
Cause: Missing team_id check in queries or authorization
Solution: Always scope queries to current team:
// ❌ Wrong - shows all companies
$companies = Company::all();
// ✓ Correct - only current team's companies
$companies = auth()->user()
->currentTeam()
->companies()
->get();
// Or use query scope (see Chapter 09)
$companies = Company::forTeam(auth()->user()->currentTeam())->get();Team Switching Problems
Problem: Team switcher doesn't work
Symptom: Selecting a team in dropdown does nothing
Cause: Route not registered or authorization fails
Solution:
- Check route exists:
sail artisan route:list | grep switch
# Should show: POST /teams/{team}/switch- Check authorization allows switching:
// In TeamController::switchTeam()
$this->authorize('switch', $team); // Needs policy method
// Or temporarily remove for testing
// (we'll add proper authorization in Chapter 09)- Check browser console for JavaScript errors
Wrap-up
Congratulations! You've successfully implemented comprehensive team management for your CRM. Here's what you've accomplished:
✓ What You've Built
- Team Model Relationships — Complete many-to-many relationship between users and teams with role tracking
- Team Creation Interface — React component and controller for creating teams
- User Invitations — Email-based invitation system with token-based acceptance
- Team Switching — Users can manage multiple teams and switch between them
- Team Settings Page — Members list and management interface
- Helper Methods — Convenient methods for checking ownership and roles
✓ Key Concepts You've Learned
- Many-to-Many Relationships — Using pivot tables to store additional data (role)
- Email Notifications — Sending emails via Laravel Mail with invitations
- Token-Based Links — Secure, stateless invitation acceptance
- Session Management — Tracking current team in
current_team_id - Middleware — Ensuring users have a selected team before accessing protected routes
- Role-Based Data — Storing and accessing role information via pivot tables
✓ Architecture Patterns
The team system you've built follows these enterprise patterns:
Team Lifecycle & Data Flow:
Pattern Summary:
User creates Team
↓
Team owner = User (1:1 via user_id)
↓
Owner adds Members via Invitations
↓
Users attached to Team (M:M via team_user pivot)
↓
Role stored in pivot table (owner, admin, member)
↓
Current team tracked per user session (current_team_id)
↓
All CRM data scoped by team_id (coming Chapter 09)🔐 Global Scopes Preview (Chapter 09+)
The current_team_id you've implemented will enable automatic query filtering via Global Scopes in Chapter 09:
// Without Global Scope (❌ can see other teams' data):
$companies = Company::all();
// With Global Scope (✓ only sees current team's data):
$companies = Company::all(); // Automatically filtered by team_id!
// This is implemented in the model via addGlobalScope():
class Company extends Model
{
protected static function booted(): void
{
static::addGlobalScope(new TeamScope);
}
}
// The TeamScope automatically adds WHERE team_id = current_team_id to all queriesThis automatic filtering is why tracking current_team_id on the user is so important—it becomes the filter for all queries across the CRM.
🎯 Next Steps
You're now ready for Chapter 09: Authorization & Access Control, where you'll:
- Create policies for Contact, Company, Deal, and Task models
- Implement team-scoped authorization so users can only access their team's data
- Enforce role-based permissions (only owners can delete, admins can manage members, etc.)
- Add policy checks to all controllers and views
- Ensure complete data isolation between teams
Your CRM will evolve from "supporting multiple teams" to "completely isolating each team's data with role-based permissions"—the foundation of a production-ready multi-tenant SaaS application.
📋 Validation Checklist
Before moving to Chapter 09, verify:
- [ ] Teams can be created via UI
- [ ] Current user is automatically added as team owner
- [ ] Team switcher appears in navigation
- [ ] Users can switch between teams they belong to
- [ ] Invitations are sent and received via Mailhog
- [ ] Invited users can accept invitations
- [ ] Members list shows all team members with roles
- [ ] Team owners can remove members
- [ ] New users default to their first team
- [ ] All helper methods work (isTeamOwner, getRoleOnTeam, etc.)
- [ ] Running
sail artisan tinkershows no errors
If all checks pass, you're ready for authorization in the next chapter!
Further Reading
- Eloquent Many-to-Many — Pivot tables and role tracking
- Laravel Mail — Sending emails and mailables
- Mailhog — Email testing in development
- Session Management — Storing user state
- Middleware — Creating custom HTTP middleware
- Inertia.js — Handling forms in React with Inertia
- shadcn/ui — Pre-built React components used in forms