Building SaaS with Laravel (Part 4): Team Management & Permissions

· 16 min read

In Part 3, we integrated billing. Now it's time for the next critical piece: team management — allowing multiple people to collaborate within an organization.

1. Designing the Roles & Permissions System

Roles within a Tenant

Role Description Permissions
Owner Tenant creator Full access, including billing & deleting tenant
Admin Administrator Manage members, teams, projects
Member Team member Create/edit projects, tasks
Viewer Read-only View projects, tasks (no editing)

Permissions

// Tenant-level
manage-tenant        # Edit settings, domain
manage-billing       # Manage subscription
delete-tenant        # Delete tenant

// Member-level
invite-members       # Invite new members
remove-members       # Remove members
manage-roles         # Change roles

// Team-level
create-teams         # Create teams
manage-teams         # Edit/delete teams

// Project-level
create-projects      # Create projects
manage-projects      # Edit/delete projects
archive-projects     # Archive projects

// Task-level
create-tasks         # Create tasks
assign-tasks         # Assign tasks to others
manage-all-tasks     # Edit/delete all tasks

2. Setting Up Spatie Permission

php artisan vendor:publish --provider="Spatie\Permission\PermissionServiceProvider"
php artisan migrate

Roles & Permissions Seeder

// database/seeders/RolesAndPermissionsSeeder.php
<?php

namespace Database\Seeders;

use Illuminate\Database\Seeder;
use Spatie\Permission\Models\Permission;
use Spatie\Permission\Models\Role;

class RolesAndPermissionsSeeder extends Seeder
{
    public function run(): void
    {
        // Reset cached roles and permissions
        app()[\Spatie\Permission\PermissionRegistrar::class]->forgetCachedPermissions();

        $permissions = [
            // Tenant
            'manage-tenant',
            'manage-billing',
            'delete-tenant',
            // Members
            'invite-members',
            'remove-members',
            'manage-roles',
            // Teams
            'create-teams',
            'manage-teams',
            // Projects
            'create-projects',
            'manage-projects',
            'archive-projects',
            // Tasks
            'create-tasks',
            'assign-tasks',
            'manage-all-tasks',
        ];

        foreach ($permissions as $permission) {
            Permission::firstOrCreate(['name' => $permission]);
        }

        // Owner - has all permissions
        Role::firstOrCreate(['name' => 'owner'])
            ->givePermissionTo(Permission::all());

        // Admin
        Role::firstOrCreate(['name' => 'admin'])
            ->givePermissionTo([
                'invite-members',
                'remove-members',
                'manage-roles',
                'create-teams',
                'manage-teams',
                'create-projects',
                'manage-projects',
                'archive-projects',
                'create-tasks',
                'assign-tasks',
                'manage-all-tasks',
            ]);

        // Member
        Role::firstOrCreate(['name' => 'member'])
            ->givePermissionTo([
                'create-projects',
                'create-tasks',
                'assign-tasks',
            ]);

        // Viewer
        Role::firstOrCreate(['name' => 'viewer']);
        // No special permissions - read-only
    }
}

3. Team Invitation System

Migration

// database/migrations/2026_03_18_000001_create_team_invitations_table.php
<?php

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('team_invitations', function (Blueprint $table) {
            $table->id();
            $table->foreignId('tenant_id')->constrained()->cascadeOnDelete();
            $table->foreignId('invited_by')->constrained('users');
            $table->string('email');
            $table->string('role')->default('member');
            $table->string('token', 64)->unique();
            $table->timestamp('expires_at');
            $table->timestamp('accepted_at')->nullable();
            $table->timestamps();

            $table->unique(['tenant_id', 'email']);
            $table->index('token');
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('team_invitations');
    }
};

Invitation Model

// app/Domain/Team/Models/TeamInvitation.php
<?php

declare(strict_types=1);

namespace App\Domain\Team\Models;

use App\Domain\Tenant\Models\Tenant;
use App\Domain\Tenant\Traits\BelongsToTenant;
use App\Domain\User\Models\User;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;

class TeamInvitation extends Model
{
    use BelongsToTenant;

    protected $fillable = [
        'tenant_id',
        'invited_by',
        'email',
        'role',
        'token',
        'expires_at',
        'accepted_at',
    ];

    protected $casts = [
        'expires_at' => 'datetime',
        'accepted_at' => 'datetime',
    ];

    public function tenant(): BelongsTo
    {
        return $this->belongsTo(Tenant::class);
    }

    public function inviter(): BelongsTo
    {
        return $this->belongsTo(User::class, 'invited_by');
    }

    public function isExpired(): bool
    {
        return $this->expires_at->isPast();
    }

    public function isAccepted(): bool
    {
        return $this->accepted_at !== null;
    }

    public function isPending(): bool
    {
        return ! $this->isAccepted() && ! $this->isExpired();
    }
}

4. Invite Action

// app/Domain/Team/Actions/InviteMemberAction.php
<?php

declare(strict_types=1);

namespace App\Domain\Team\Actions;

use App\Domain\Team\Models\TeamInvitation;
use App\Domain\Tenant\Models\Tenant;
use App\Domain\User\Models\User;
use App\Notifications\TeamInvitationNotification;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;

class InviteMemberAction
{
    public function execute(
        Tenant $tenant,
        User $inviter,
        string $email,
        string $role = 'member',
    ): TeamInvitation {
        // Check if user is already a member
        $existingUser = User::where('email', $email)->first();
        if ($existingUser && $tenant->users()->where('user_id', $existingUser->id)->exists()) {
            throw ValidationException::withMessages([
                'email' => 'This user is already a member.',
            ]);
        }

        // Check if there's already a pending invitation
        $existingInvite = TeamInvitation::withoutTenantScope()
            ->where('tenant_id', $tenant->id)
            ->where('email', $email)
            ->whereNull('accepted_at')
            ->where('expires_at', '>', now())
            ->first();

        if ($existingInvite) {
            throw ValidationException::withMessages([
                'email' => 'An invitation has already been sent to this email.',
            ]);
        }

        // Check member limit based on plan
        $memberLimit = $tenant->planLimit('members');
        $currentMembers = $tenant->users()->count();

        if ($memberLimit !== -1 && $currentMembers >= $memberLimit) {
            throw ValidationException::withMessages([
                'email' => "Your current plan only allows {$memberLimit} members. Please upgrade.",
            ]);
        }

        $invitation = TeamInvitation::create([
            'tenant_id' => $tenant->id,
            'invited_by' => $inviter->id,
            'email' => $email,
            'role' => $role,
            'token' => Str::random(64),
            'expires_at' => now()->addDays(7),
        ]);

        // Send invitation email
        if ($existingUser) {
            $existingUser->notify(new TeamInvitationNotification($invitation));
        } else {
            // Send email to users who don't have an account yet
            \Illuminate\Support\Facades\Notification::route('mail', $email)
                ->notify(new TeamInvitationNotification($invitation));
        }

        return $invitation;
    }
}

5. Accept Invitation Action

// app/Domain/Team/Actions/AcceptInvitationAction.php
<?php

declare(strict_types=1);

namespace App\Domain\Team\Actions;

use App\Domain\Team\Models\TeamInvitation;
use App\Domain\User\Models\User;

class AcceptInvitationAction
{
    public function execute(TeamInvitation $invitation, User $user): void
    {
        if ($invitation->isExpired()) {
            throw new \DomainException('This invitation has expired.');
        }

        if ($invitation->isAccepted()) {
            throw new \DomainException('This invitation has already been accepted.');
        }

        $tenant = $invitation->tenant;

        // Add user to tenant
        $tenant->users()->attach($user->id, [
            'role' => $invitation->role,
        ]);

        // Assign role (Spatie)
        $user->assignRole($invitation->role);

        // Mark invitation as accepted
        $invitation->update(['accepted_at' => now()]);

        // Set current tenant if user doesn't have one
        if (! $user->current_tenant_id) {
            $user->update(['current_tenant_id' => $tenant->id]);
        }
    }
}

6. Invitation Controller

// app/Http/Controllers/Team/InvitationController.php
<?php

declare(strict_types=1);

namespace App\Http\Controllers\Team;

use App\Domain\Team\Actions\AcceptInvitationAction;
use App\Domain\Team\Actions\InviteMemberAction;
use App\Domain\Team\Models\TeamInvitation;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;

class InvitationController extends Controller
{
    public function store(Request $request, InviteMemberAction $action)
    {
        $this->authorize('invite-members');

        $validated = $request->validate([
            'email' => 'required|email|max:255',
            'role' => 'required|in:admin,member,viewer',
        ]);

        $invitation = $action->execute(
            tenant: current_tenant(),
            inviter: $request->user(),
            email: $validated['email'],
            role: $validated['role'],
        );

        return back()->with('success', "Invitation sent to {$validated['email']}");
    }

    public function accept(string $token, AcceptInvitationAction $action)
    {
        $invitation = TeamInvitation::withoutTenantScope()
            ->where('token', $token)
            ->firstOrFail();

        if ($invitation->isExpired()) {
            return view('team.invitation-expired');
        }

        if ($invitation->isAccepted()) {
            return redirect()->route('dashboard')
                ->with('info', 'This invitation has already been accepted.');
        }

        $user = auth()->user();

        if (! $user) {
            // Store token in session, redirect to register/login
            session(['invitation_token' => $token]);

            return redirect()->route('register')
                ->with('info', "You've been invited to {$invitation->tenant->name}. Sign up to continue.");
        }

        $action->execute($invitation, $user);

        return redirect()->route('dashboard')
            ->with('success', "You've joined {$invitation->tenant->name}!");
    }

    public function cancel(TeamInvitation $invitation)
    {
        $this->authorize('invite-members');

        if ($invitation->isAccepted()) {
            return back()->withErrors(['invitation' => 'This invitation has already been accepted and cannot be cancelled.']);
        }

        $invitation->delete();

        return back()->with('success', 'Invitation has been cancelled.');
    }
}

7. Member Management Controller

// app/Http/Controllers/Team/MemberController.php
<?php

declare(strict_types=1);

namespace App\Http\Controllers\Team;

use App\Domain\User\Models\User;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;

class MemberController extends Controller
{
    public function index()
    {
        $tenant = current_tenant();
        $members = $tenant->users()
            ->withPivot('role')
            ->orderByPivot('role')
            ->get();

        $pendingInvitations = $tenant->invitations()
            ->whereNull('accepted_at')
            ->where('expires_at', '>', now())
            ->with('inviter')
            ->get();

        return view('team.members', [
            'members' => $members,
            'pendingInvitations' => $pendingInvitations,
        ]);
    }

    public function updateRole(Request $request, User $user)
    {
        $this->authorize('manage-roles');

        $validated = $request->validate([
            'role' => 'required|in:admin,member,viewer',
        ]);

        $tenant = current_tenant();

        // Cannot change the owner's role
        if ($tenant->owner_id === $user->id) {
            return back()->withErrors(['role' => 'Cannot change the owner\'s role.']);
        }

        // Update role in pivot table
        $tenant->users()->updateExistingPivot($user->id, [
            'role' => $validated['role'],
        ]);

        // Sync Spatie role
        $user->syncRoles([$validated['role']]);

        return back()->with('success', "Updated role for {$user->name}.");
    }

    public function remove(Request $request, User $user)
    {
        $this->authorize('remove-members');

        $tenant = current_tenant();

        // Cannot remove the owner
        if ($tenant->owner_id === $user->id) {
            return back()->withErrors(['member' => 'Cannot remove the owner from the organization.']);
        }

        // Cannot remove yourself
        if ($request->user()->id === $user->id) {
            return back()->withErrors(['member' => 'Cannot remove yourself. Use the leave option instead.']);
        }

        // Detach user from tenant
        $tenant->users()->detach($user->id);

        // If user is currently on this tenant, clear current_tenant
        if ($user->current_tenant_id === $tenant->id) {
            $firstTenant = $user->tenants()->first();
            $user->update([
                'current_tenant_id' => $firstTenant?->id,
            ]);
        }

        return back()->with('success', "{$user->name} has been removed from the organization.");
    }

    public function leave(Request $request)
    {
        $user = $request->user();
        $tenant = current_tenant();

        // Owner cannot leave
        if ($tenant->owner_id === $user->id) {
            return back()->withErrors([
                'leave' => 'Owner cannot leave the organization. Transfer ownership first.',
            ]);
        }

        $tenant->users()->detach($user->id);

        // Switch to another tenant
        $nextTenant = $user->tenants()->first();
        $user->update(['current_tenant_id' => $nextTenant?->id]);

        if ($nextTenant) {
            return redirect()->route('dashboard')
                ->with('info', "You have left {$tenant->name}.");
        }

        return redirect()->route('onboarding')
            ->with('info', 'You have left all organizations. Create a new one.');
    }
}

8. Notification: Team Invitation Email

// app/Notifications/TeamInvitationNotification.php
<?php

declare(strict_types=1);

namespace App\Notifications;

use App\Domain\Team\Models\TeamInvitation;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;

class TeamInvitationNotification extends Notification implements ShouldQueue
{
    use Queueable;

    public function __construct(
        private readonly TeamInvitation $invitation,
    ) {}

    public function via(object $notifiable): array
    {
        return ['mail'];
    }

    public function toMail(object $notifiable): MailMessage
    {
        $acceptUrl = route('invitation.accept', $this->invitation->token);

        return (new MailMessage())
            ->subject("You're invited to {$this->invitation->tenant->name}")
            ->greeting('Hello!')
            ->line("{$this->invitation->inviter->name} has invited you to join **{$this->invitation->tenant->name}** as a **{$this->invitation->role}**.")
            ->action('Accept Invitation', $acceptUrl)
            ->line("This invitation expires on {$this->invitation->expires_at->format('M d, Y H:i')}.")
            ->line('If you did not expect this invitation, please ignore this email.');
    }
}

9. Blade Views

Members List

{{-- resources/views/team/members.blade.php --}}
<div class="max-w-4xl mx-auto py-8">
    <div class="flex justify-between items-center mb-6">
        <h2 class="text-2xl font-bold">Members</h2>

        @can('invite-members')
        <button
            @click="$dispatch('open-invite-modal')"
            class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
        >
            Invite Member
        </button>
        @endcan
    </div>

    {{-- Members table --}}
    <div class="bg-white dark:bg-gray-800 rounded-lg shadow">
        @foreach($members as $member)
        <div class="flex items-center justify-between p-4 border-b dark:border-gray-700 last:border-0">
            <div class="flex items-center gap-4">
                <div class="w-10 h-10 rounded-full bg-gray-200 dark:bg-gray-700 flex items-center justify-center">
                    {{ strtoupper(substr($member->name, 0, 1)) }}
                </div>
                <div>
                    <div class="font-medium">
                        {{ $member->name }}
                        @if($member->id === current_tenant()->owner_id)
                            <span class="text-xs bg-yellow-100 text-yellow-800 px-2 py-0.5 rounded-full ml-2">
                                Owner
                            </span>
                        @endif
                    </div>
                    <div class="text-sm text-gray-500">{{ $member->email }}</div>
                </div>
            </div>

            <div class="flex items-center gap-4">
                @can('manage-roles')
                @if($member->id !== current_tenant()->owner_id)
                <form method="POST" action="{{ route('members.update-role', $member) }}">
                    @csrf
                    @method('PUT')
                    <select
                        name="role"
                        onchange="this.form.submit()"
                        class="text-sm border rounded-lg px-3 py-1 dark:bg-gray-700"
                    >
                        @foreach(['admin', 'member', 'viewer'] as $role)
                        <option value="{{ $role }}" {{ $member->pivot->role === $role ? 'selected' : '' }}>
                            {{ ucfirst($role) }}
                        </option>
                        @endforeach
                    </select>
                </form>
                @endif
                @endcan

                @can('remove-members')
                @if($member->id !== current_tenant()->owner_id && $member->id !== auth()->id())
                <form method="POST" action="{{ route('members.remove', $member) }}">
                    @csrf
                    @method('DELETE')
                    <button
                        type="submit"
                        onclick="return confirm('Are you sure you want to remove {{ $member->name }}?')"
                        class="text-red-500 hover:text-red-700 text-sm"
                    >
                        Remove
                    </button>
                </form>
                @endif
                @endcan
            </div>
        </div>
        @endforeach
    </div>

    {{-- Pending invitations --}}
    @if($pendingInvitations->isNotEmpty())
    <h3 class="text-lg font-semibold mt-8 mb-4">Pending Invitations</h3>
    <div class="bg-white dark:bg-gray-800 rounded-lg shadow">
        @foreach($pendingInvitations as $invite)
        <div class="flex items-center justify-between p-4 border-b dark:border-gray-700 last:border-0">
            <div>
                <div class="font-medium">{{ $invite->email }}</div>
                <div class="text-sm text-gray-500">
                    Invited by {{ $invite->inviter->name }} · Expires {{ $invite->expires_at->diffForHumans() }}
                </div>
            </div>
            <div class="flex items-center gap-2">
                <span class="text-xs bg-blue-100 text-blue-800 px-2 py-0.5 rounded-full">
                    {{ ucfirst($invite->role) }}
                </span>
                <form method="POST" action="{{ route('invitation.cancel', $invite) }}">
                    @csrf
                    @method('DELETE')
                    <button class="text-red-500 hover:text-red-700 text-sm">Cancel</button>
                </form>
            </div>
        </div>
        @endforeach
    </div>
    @endif
</div>

10. Team Management Routes

// routes/web.php - add to tenant group
Route::middleware(['auth', 'tenant'])->group(function () {
    // Members
    Route::get('/members', [MemberController::class, 'index'])->name('members.index');
    Route::put('/members/{user}/role', [MemberController::class, 'updateRole'])->name('members.update-role');
    Route::delete('/members/{user}', [MemberController::class, 'remove'])->name('members.remove');
    Route::post('/members/leave', [MemberController::class, 'leave'])->name('members.leave');

    // Invitations
    Route::post('/invitations', [InvitationController::class, 'store'])->name('invitation.store');
    Route::delete('/invitations/{invitation}', [InvitationController::class, 'cancel'])->name('invitation.cancel');
});

// Public route for accepting invitations
Route::get('/invitation/{token}', [InvitationController::class, 'accept'])->name('invitation.accept');

11. Authorization Middleware

Leveraging Spatie Permission in controllers:

// Method 1: In controller method
public function store(Request $request)
{
    $this->authorize('create-projects');
    // ...
}

// Method 2: Middleware on route
Route::post('/projects', [ProjectController::class, 'store'])
    ->middleware('permission:create-projects');

// Method 3: Blade directive
@can('create-projects')
    <button>Create Project</button>
@endcan

12. Testing Team Management

// tests/Feature/TeamManagementTest.php
<?php

use App\Domain\Team\Actions\AcceptInvitationAction;
use App\Domain\Team\Actions\InviteMemberAction;
use App\Domain\Tenant\Actions\CreateTenantAction;
use App\Domain\User\Models\User;
use App\Providers\TenantServiceProvider;

beforeEach(function () {
    $this->seed(\Database\Seeders\RolesAndPermissionsSeeder::class);

    $this->owner = User::factory()->create();
    $this->tenant = (new CreateTenantAction())->execute($this->owner, 'Test Co');
    $this->owner->assignRole('owner');
    TenantServiceProvider::setTenant($this->tenant);
});

test('owner can invite members', function () {
    $action = new InviteMemberAction();

    $invitation = $action->execute(
        $this->tenant,
        $this->owner,
        'newmember@example.com',
        'member',
    );

    expect($invitation)
        ->email->toBe('newmember@example.com')
        ->role->toBe('member')
        ->isPending()->toBeTrue();
});

test('cannot invite existing member', function () {
    $existingUser = User::factory()->create();
    $this->tenant->users()->attach($existingUser->id, ['role' => 'member']);

    $action = new InviteMemberAction();

    expect(fn () => $action->execute(
        $this->tenant,
        $this->owner,
        $existingUser->email,
    ))->toThrow(\Illuminate\Validation\ValidationException::class);
});

test('member can accept invitation', function () {
    $inviteAction = new InviteMemberAction();
    $acceptAction = new AcceptInvitationAction();

    $invitation = $inviteAction->execute(
        $this->tenant,
        $this->owner,
        'new@example.com',
    );

    $newUser = User::factory()->create(['email' => 'new@example.com']);
    $acceptAction->execute($invitation, $newUser);

    expect($invitation->fresh()->isAccepted())->toBeTrue()
        ->and($this->tenant->users)->toHaveCount(2)
        ->and($newUser->fresh()->current_tenant_id)->toBe($this->tenant->id);
});

test('expired invitation cannot be accepted', function () {
    $action = new InviteMemberAction();

    $invitation = $action->execute(
        $this->tenant,
        $this->owner,
        'expired@example.com',
    );

    // Simulate expiration
    $invitation->update(['expires_at' => now()->subHour()]);

    $newUser = User::factory()->create(['email' => 'expired@example.com']);

    expect(fn () => (new AcceptInvitationAction())->execute($invitation, $newUser))
        ->toThrow(\DomainException::class);
});

test('owner cannot be removed', function () {
    $this->actingAs($this->owner)
        ->delete("/members/{$this->owner->id}")
        ->assertSessionHasErrors('member');
});

test('non-admin cannot invite members', function () {
    $member = User::factory()->create();
    $this->tenant->users()->attach($member->id, ['role' => 'member']);
    $member->assignRole('member');

    $this->actingAs($member)
        ->post('/invitations', [
            'email' => 'someone@example.com',
            'role' => 'member',
        ])
        ->assertForbidden();
});

test('member can leave organization', function () {
    $member = User::factory()->create(['current_tenant_id' => $this->tenant->id]);
    $this->tenant->users()->attach($member->id, ['role' => 'member']);

    $this->actingAs($member)
        ->post('/members/leave')
        ->assertRedirect();

    expect($this->tenant->users()->where('user_id', $member->id)->exists())->toBeFalse();
});

Summary

In Part 4, we built:

  • 4 roles with granular permissions using Spatie
  • Invitation system with tokens, expiry, and email notifications
  • Member management: invite, accept, change role, remove, leave
  • Authorization at multiple levels: middleware, policy, Blade
  • Protection to prevent the owner from being removed/demoted
  • Comprehensive test suite for team management

Key takeaways:

  • Always check authorization on the backend, not just by hiding UI elements
  • Invitation tokens must have expiry and be single-use
  • The owner must be specially protected
  • When removing a member, handle current_tenant_id properly

Next up: Plans, Limits & Usage Tracking — enforcing plan limits, tracking usage, and displaying quotas to users.

Comments