Xây dựng SaaS với Laravel (Phần 4): Team Management & Phân quyền
·
18 min read
Ở Phần 3, chúng ta đã tích hợp billing. Giờ đến phần quan trọng tiếp theo: team management — cho phép nhiều người cùng làm việc trong một tổ chức.
1. Thiết kế hệ thống Roles & Permissions
Roles trong Tenant
| Role | Mô tả | Quyền |
|---|---|---|
| Owner | Người tạo tenant | Toàn quyền, bao gồm billing & xóa tenant |
| Admin | Quản trị viên | Quản lý members, teams, projects |
| Member | Thành viên | Tạo/sửa projects, tasks |
| Viewer | Chỉ xem | Xem projects, tasks (không sửa) |
Permissions
// Tenant-level
manage-tenant # Sửa settings, domain
manage-billing # Quản lý subscription
delete-tenant # Xóa tenant
// Member-level
invite-members # Mời thành viên mới
remove-members # Xóa thành viên
manage-roles # Thay đổi roles
// Team-level
create-teams # Tạo teams
manage-teams # Sửa/xóa teams
// Project-level
create-projects # Tạo projects
manage-projects # Sửa/xóa projects
archive-projects # Lưu trữ projects
// Task-level
create-tasks # Tạo tasks
assign-tasks # Gán tasks cho người khác
manage-all-tasks # Sửa/xóa tất cả tasks
2. Setup Spatie Permission
php artisan vendor:publish --provider="Spatie\Permission\PermissionServiceProvider"
php artisan migrate
Seeder cho Roles & Permissions
// 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 - có tất cả quyền
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']);
// Không có permissions đặc biệt - chỉ xem
}
}
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 {
// Kiểm tra user đã là member chưa
$existingUser = User::where('email', $email)->first();
if ($existingUser && $tenant->users()->where('user_id', $existingUser->id)->exists()) {
throw ValidationException::withMessages([
'email' => 'Người dùng này đã là thành viên.',
]);
}
// Kiểm tra đã có invitation pending chưa
$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' => 'Đã gửi lời mời cho email này rồi.',
]);
}
// Kiểm tra giới hạn members theo plan
$memberLimit = $tenant->planLimit('members');
$currentMembers = $tenant->users()->count();
if ($memberLimit !== -1 && $currentMembers >= $memberLimit) {
throw ValidationException::withMessages([
'email' => "Plan hiện tại chỉ cho phép {$memberLimit} thành viên. Vui lòng nâng cấp.",
]);
}
$invitation = TeamInvitation::create([
'tenant_id' => $tenant->id,
'invited_by' => $inviter->id,
'email' => $email,
'role' => $role,
'token' => Str::random(64),
'expires_at' => now()->addDays(7),
]);
// Gửi email invitation
if ($existingUser) {
$existingUser->notify(new TeamInvitationNotification($invitation));
} else {
// Gửi email cho người chưa có tài khoản
\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('Lời mời đã hết hạn.');
}
if ($invitation->isAccepted()) {
throw new \DomainException('Lời mời đã được chấp nhận.');
}
$tenant = $invitation->tenant;
// Thêm user vào tenant
$tenant->users()->attach($user->id, [
'role' => $invitation->role,
]);
// Assign role (Spatie)
$user->assignRole($invitation->role);
// Đánh dấu invitation đã được chấp nhận
$invitation->update(['accepted_at' => now()]);
// Set current tenant nếu user chưa có
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', "Lời mời đã được gửi tới {$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', 'Lời mời này đã được chấp nhận.');
}
$user = auth()->user();
if (! $user) {
// Lưu token vào session, redirect về register/login
session(['invitation_token' => $token]);
return redirect()->route('register')
->with('info', "Bạn được mời vào {$invitation->tenant->name}. Đăng ký để tiếp tục.");
}
$action->execute($invitation, $user);
return redirect()->route('dashboard')
->with('success', "Bạn đã tham gia {$invitation->tenant->name}!");
}
public function cancel(TeamInvitation $invitation)
{
$this->authorize('invite-members');
if ($invitation->isAccepted()) {
return back()->withErrors(['invitation' => 'Lời mời đã được chấp nhận, không thể hủy.']);
}
$invitation->delete();
return back()->with('success', 'Lời mời đã được hủy.');
}
}
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();
// Không thể thay đổi role của owner
if ($tenant->owner_id === $user->id) {
return back()->withErrors(['role' => 'Không thể thay đổi role của owner.']);
}
// Cập nhật role trong pivot table
$tenant->users()->updateExistingPivot($user->id, [
'role' => $validated['role'],
]);
// Sync Spatie role
$user->syncRoles([$validated['role']]);
return back()->with('success', "Đã cập nhật role cho {$user->name}.");
}
public function remove(Request $request, User $user)
{
$this->authorize('remove-members');
$tenant = current_tenant();
// Không thể xóa owner
if ($tenant->owner_id === $user->id) {
return back()->withErrors(['member' => 'Không thể xóa owner khỏi tổ chức.']);
}
// Không thể tự xóa mình
if ($request->user()->id === $user->id) {
return back()->withErrors(['member' => 'Không thể tự xóa mình. Hãy rời tổ chức.']);
}
// Detach user khỏi tenant
$tenant->users()->detach($user->id);
// Nếu user đang ở tenant này, 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} đã bị xóa khỏi tổ chức.");
}
public function leave(Request $request)
{
$user = $request->user();
$tenant = current_tenant();
// Owner không thể rời
if ($tenant->owner_id === $user->id) {
return back()->withErrors([
'leave' => 'Owner không thể rời tổ chức. Hãy chuyển ownership trước.',
]);
}
$tenant->users()->detach($user->id);
// Switch sang tenant khác
$nextTenant = $user->tenants()->first();
$user->update(['current_tenant_id' => $nextTenant?->id]);
if ($nextTenant) {
return redirect()->route('dashboard')
->with('info', "Bạn đã rời {$tenant->name}.");
}
return redirect()->route('onboarding')
->with('info', 'Bạn đã rời tất cả tổ chức. Tạo một tổ chức mới.');
}
}
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("Bạn được mời vào {$this->invitation->tenant->name}")
->greeting('Xin chào!')
->line("{$this->invitation->inviter->name} đã mời bạn tham gia **{$this->invitation->tenant->name}** với vai trò **{$this->invitation->role}**.")
->action('Chấp nhận lời mời', $acceptUrl)
->line("Lời mời sẽ hết hạn vào {$this->invitation->expires_at->format('d/m/Y H:i')}.")
->line('Nếu bạn không mong đợi lời mời này, hãy bỏ qua 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">Thành viên</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"
>
Mời thành viên
</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('Bạn chắc chắn muốn xóa {{ $member->name }}?')"
class="text-red-500 hover:text-red-700 text-sm"
>
Xóa
</button>
</form>
@endif
@endcan
</div>
</div>
@endforeach
</div>
{{-- Pending invitations --}}
@if($pendingInvitations->isNotEmpty())
<h3 class="text-lg font-semibold mt-8 mb-4">Lời mời đang chờ</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">
Mời bởi {{ $invite->inviter->name }} · Hết hạn {{ $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">Hủy</button>
</form>
</div>
</div>
@endforeach
</div>
@endif
</div>
10. Routes cho Team Management
// routes/web.php - thêm vào 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 cho accept invitation
Route::get('/invitation/{token}', [InvitationController::class, 'accept'])->name('invitation.accept');
11. Authorization Middleware
Tận dụng Spatie Permission trong controllers:
// Cách 1: Trong controller method
public function store(Request $request)
{
$this->authorize('create-projects');
// ...
}
// Cách 2: Middleware trên route
Route::post('/projects', [ProjectController::class, 'store'])
->middleware('permission:create-projects');
// Cách 3: Blade directive
@can('create-projects')
<button>Tạo 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',
);
// Giả lập hết hạn
$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();
});
Tổng kết
Ở phần 4, chúng ta đã xây dựng:
- 4 roles với permissions chi tiết sử dụng Spatie
- Invitation system với token, expiry, email notification
- Member management: invite, accept, change role, remove, leave
- Authorization ở nhiều tầng: middleware, policy, blade
- Bảo vệ owner không bị xóa/hạ role
- Test suite đầy đủ cho team management
Key takeaways:
- Luôn kiểm tra authorization ở cả backend, không chỉ ẩn UI
- Invitation token phải có thời hạn và chỉ dùng một lần
- Owner phải được bảo vệ đặc biệt
- Khi remove member, phải handle
current_tenant_idđúng cách
Phần tiếp theo: Plans, Limits & Usage Tracking — enforce giới hạn theo plan, tracking usage, và hiển thị quota cho users.