Phân quyền User/Admin chuẩn Enterprise: Từ bỏ if/else, làm chủ Spatie và PHP Enums
Nhiều anh em Junior thường giải quyết bài toán phân quyền bằng cách thêm một cột is_admin (boolean) hoặc role (string 'admin', 'user') vào bảng users. Trong code thì rải đầy rẫy các câu lệnh if ($user->role == 'admin').
Tại sao cách này là "Tội ác" ở các dự án Enterprise?
Vì hệ thống không bao giờ chỉ có 2 role. Một ngày đẹp trời sếp bảo: "Thêm cho anh role Manager (Quản lý), role Editor (Người viết bài) và role Reviewer (Người duyệt bài) nhé". Lúc đó, cái đống if/else của bạn sẽ sụp đổ, code rối như tơ vò và cực kỳ dễ sinh ra lỗ hổng bảo mật (Privilege Escalation).
Hôm nay, mình sẽ hướng dẫn anh em đập bỏ tư duy if/else, sử dụng package huyền thoại Spatie Laravel Permission kết hợp với PHP Enums để xây dựng một hệ thống RBAC (Role-Based Access Control) chuẩn mực, linh hoạt và "chống đạn".
Lời mở đầu: Role và Permission khác nhau thế nào?
Trước khi code, phải thông não kiến trúc này:
- Role (Vai trò): Là một cái danh xưng (VD:
Admin,Editor,User). - Permission (Quyền hạn): Là hành động cụ thể (VD:
create_post,delete_post,view_post).
Nguyên lý vàng: Chúng ta gom các Permissions nhét vào Role. Sau đó gán Role cho User.
Trong Controller, chúng ta KHÔNG check Role, chúng ta CHECK Permission.
Ví dụ: Thay vì hỏi "Mày có phải là Admin không?", hãy hỏi "Mày có quyền Xóa Bài Viết không?". Nhờ vậy, sau này muốn cho Editor được xóa bài, ta chỉ cần gán quyền delete_post cho role Editor trong Database, không phải sửa 1 dòng code logic nào!
Bước 1: Khởi tạo và Cài đặt "Vũ khí" Spatie
Tạo dự án mới:
laravel new enterprise-rbac
cd enterprise-rbac
Cài đặt package spatie/laravel-permission (Tiêu chuẩn công nghiệp của hệ sinh thái Laravel):
composer require spatie/laravel-permission
Publish file config và migration của Spatie:
php artisan vendor:publish --provider="Spatie\Permission\PermissionServiceProvider"
Chạy migration để tạo các bảng (roles, permissions, model_has_roles...):
php artisan migrate
Mở Model User.php và thêm trait HasRoles:
// app/Models/User.php
namespace App\Models;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Spatie\Permission\Traits\HasRoles; // <--- THÊM DÒNG NÀY
class User extends Authenticatable
{
use HasRoles; // <--- SỬ DỤNG TRAIT NÀY
protected $fillable = ['name', 'email', 'password'];
// ...
}
Bước 2: Chuẩn hóa bằng PHP Enums (Đẳng cấp Senior)
Thay vì gõ text 'admin' hay 'delete_post' khắp nơi (rất dễ gõ sai chính tả), ta dùng PHP Enums để quản lý tập trung.
Tạo thư mục app/Enums và tạo 2 file:
1. RoleEnum.php
namespace App\Enums;
enum RoleEnum: string
{
case ADMIN = 'admin';
case EDITOR = 'editor';
case USER = 'user';
}
2. PermissionEnum.php
namespace App\Enums;
enum PermissionEnum: string
{
case VIEW_POST = 'view_post';
case CREATE_POST = 'create_post';
case EDIT_POST = 'edit_post';
case DELETE_POST = 'delete_post';
}
Bước 3: Đổ dữ liệu hạt giống (Seeding the Database)
Hệ thống phân quyền cần có data chuẩn ngay từ khi setup. Ta sẽ viết Seeder để tự động tạo Role, Permission và 2 User mẫu (1 Admin, 1 User thường).
Tạo Seeder:
php artisan make:seeder RolesAndPermissionsSeeder
Mở file vừa tạo lên:
// database/seeders/RolesAndPermissionsSeeder.php
namespace Database\Seeders;
use Illuminate\Database\Seeder;
use Spatie\Permission\Models\Role;
use Spatie\Permission\Models\Permission;
use App\Models\User;
use App\Enums\RoleEnum;
use App\Enums\PermissionEnum;
use Illuminate\Support\Facades\Hash;
class RolesAndPermissionsSeeder extends Seeder
{
public function run(): void
{
// Reset cached roles and permissions (Bắt buộc khi dùng Spatie)
app()[\Spatie\Permission\PermissionRegistrar::class]->forgetCachedPermissions();
// 1. Tạo Permissions từ Enum
foreach (PermissionEnum::cases() as $permission) {
Permission::findOrCreate($permission->value);
}
// 2. Tạo Role USER và gán quyền
$userRole = Role::findOrCreate(RoleEnum::USER->value);
$userRole->givePermissionTo([
PermissionEnum::VIEW_POST->value,
]);
// 3. Tạo Role EDITOR và gán quyền
$editorRole = Role::findOrCreate(RoleEnum::EDITOR->value);
$editorRole->givePermissionTo([
PermissionEnum::VIEW_POST->value,
PermissionEnum::CREATE_POST->value,
PermissionEnum::EDIT_POST->value,
]);
// 4. Tạo Role ADMIN và gán TẤT CẢ quyền
$adminRole = Role::findOrCreate(RoleEnum::ADMIN->value);
$adminRole->givePermissionTo(Permission::all());
// ============================================
// 5. TẠO USER MẪU ĐỂ TEST POSTMAN
// ============================================
$admin = User::firstOrCreate(
['email' => 'admin@example.com'],
['name' => 'Super Admin', 'password' => Hash::make('123456')]
);
$admin->assignRole(RoleEnum::ADMIN->value);
$user = User::firstOrCreate(
['email' => 'user@example.com'],
['name' => 'Normal User', 'password' => Hash::make('123456')]
);
$user->assignRole(RoleEnum::USER->value);
}
}
Chạy Seeder để nạp data:
php artisan db:seed --class=RolesAndPermissionsSeeder
Bước 4: Viết Controller và Bảo vệ bằng Middleware
Ta tạo một PostController giả lập việc quản lý bài viết.
php artisan make:controller Api/PostController
Spatie tích hợp sẵn vào hệ thống Policy/Gates của Laravel. Ta có thể chặn quyền ngay tại phương thức __construct() bằng Middleware can:.
// app/Http/Controllers/Api/PostController.php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Enums\PermissionEnum;
class PostController extends Controller
{
public function __construct()
{
// Ai có quyền view_post thì được gọi hàm index
$this->middleware('can:' . PermissionEnum::VIEW_POST->value)->only('index');
// Ai có quyền create_post thì được gọi hàm store
$this->middleware('can:' . PermissionEnum::CREATE_POST->value)->only('store');
// CHỈ ai có quyền delete_post mới được gọi hàm destroy
$this->middleware('can:' . PermissionEnum::DELETE_POST->value)->only('destroy');
}
public function index()
{
return response()->json(['message' => 'Lấy danh sách bài viết thành công. (Ai cũng xem được)']);
}
public function store()
{
return response()->json(['message' => 'Tạo bài viết thành công! (Chỉ Admin/Editor)']);
}
public function destroy($id)
{
return response()->json(['message' => "Xóa bài viết {$id} thành công! (Chỉ Admin)"]);
}
}
Lưu ý: Để test dễ dàng, mình tạo thêm một AuthController cực nhanh để lấy Token Sanctum.
// app/Http/Controllers/Api/AuthController.php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use App\Models\User;
use Illuminate\Support\Facades\Hash;
class AuthController extends Controller
{
public function login(Request $request)
{
$user = User::where('email', $request->email)->first();
if (!$user || !Hash::check($request->password, $user->password)) {
return response()->json(['message' => 'Sai mật khẩu'], 401);
}
$token = $user->createToken('TestToken')->plainTextToken;
return response()->json(['token' => $token, 'role' => $user->getRoleNames()]);
}
}
Đăng ký Routes (routes/api.php):
use App\Http\Controllers\Api\AuthController;
use App\Http\Controllers\Api\PostController;
Route::post('/login', [AuthController::class, 'login']);
Route::middleware('auth:sanctum')->group(function () {
Route::get('/posts', [PostController::class, 'index']);
Route::post('/posts', [PostController::class, 'store']);
Route::delete('/posts/{id}', [PostController::class, 'destroy']);
});
(Đừng quên cấu hình Laravel Sanctum trong Kernel.php hoặc bootstrap/app.php nếu bạn dùng Laravel 11 nhé).
Bước 5: Thử lửa với Postman (Phân định đẳng cấp)
Bật server: php artisan serve
Kịch bản 1: Đăng nhập lấy Token
- Lấy Token của Admin:
POST [http://127.0.0.1:8000/api/login](http://127.0.0.1:8000/api/login)- Body (JSON):
{"email": "admin@example.com", "password": "123456"} - => Copy token sinh ra.
- Lấy Token của User:
- POST
[http://127.0.0.1:8000/api/login](http://127.0.0.1:8000/api/login) - Body (JSON):
{"email": "user@example.com", "password": "123456"} - => Copy token sinh ra.
Kịch bản 2: Test quyền Xóa Bài (DELETE /posts/1)
A. Đóng vai User thường (Gắn Token của User vào Header Authorization: Bearer {token})
- Gọi
DELETE [http://127.0.0.1:8000/api/posts/1](http://127.0.0.1:8000/api/posts/1) - Kết quả: Laravel ném thẳng mã lỗi
403 Forbiddenvào mặt!
{
"message": "This action is unauthorized."
}
(Bảo mật tuyệt đối, User thường không có cửa chạm vào hàm destroy).
B. Đóng vai Admin (Đổi sang Token của Admin)
- Gọi
DELETE [http://127.0.0.1:8000/api/posts/1](http://127.0.0.1:8000/api/posts/1) - Kết quả: API nhả mã 200 OK!
{
"message": "Xóa bài viết 1 thành công! (Chỉ Admin)"
}
Kịch bản 3: Test quyền Xem danh sách (GET /posts)
Dùng Token của User hay Admin để gọi GET /posts đều ra 200 OK vì ở Seeder, ta đã cấp quyền VIEW_POST cho cả 2 Role này.
Tóm lại
Hệ thống của bạn giờ đây linh hoạt đến mức đáng sợ:
- Không có một chữ
ifnào trong Controller. Việc kiểm tra quyền được Middleware giải quyết gọn gàng ở cửa ngõ. - Dùng Enum giúp code đồng nhất, khi gõ
PermissionEnum:: IDE sẽ tự động gợi ý, xóa bỏ hoàn toàn rủi ro gõ nhầm string. - Khi Business yêu cầu thêm Role mới, bạn chỉ cần sửa file Seeder và chạy lại, code Backend hoàn toàn không bị ảnh hưởng.
Anh em hãy lôi dự án đang dùng is_admin ra đập đi xây lại theo kiến trúc này ngay nhé. Chúc anh em code sạch và an toàn!
All Rights Reserved