Policy dari chapter sebelumnya menjawab “bolehkah Budi mengubah tulisan ini”, tetapi pertanyaan “siapa saja yang boleh mengelola konten” masih kita jawab dengan membandingkan string role === 'admin' di sana-sini. Ketika jumlah peran tumbuh dan haknya mulai saling bertukar — editor boleh menulis, moderator boleh menghapus, admin boleh keduanya — kita butuh struktur data, bukan if-else yang tersebar. Chapter ini merancang sistem role dan permission dari nol: mulai dari kolom sederhana untuk aplikasi kecil, pivot table untuk aplikasi yang tumbuh, sampai tabel permission yang lengkap, lalu menghubungkan semuanya ke Gate sehingga $user->can() tetap menjadi satu pintu pemeriksaan.
Kolom Role Sederhana #
Untuk aplikasi dengan dua-tiga peran tetap, kolom role di tabel users sudah memadai:
Schema::table('users', function (Blueprint $table) {
$table->string('role')->default('penulis');
});Gate edit-settings dari chapter Gates
membaca kolom ini langsung — $user->role === 'admin' — tanpa tabel tambahan. Pendekatan ini cepat dipasang dan mudah dipahami, tetapi daftar perannya terkunci di kode: menambah peran berarti menulis migration baru, dan satu user hanya bisa memegang satu peran. Begitu kebutuhan melampaui batas itu, kita pindah ke tabel terpisah. Perlu ditegaskan bahwa kedua pendekatan tidak saling meniadakan — aplikasi nyata sering memakai kolom sederhana bertahun-tahun sebelum migrasinya ke pivot dirasa perlu, dan desain yang kita bangun berikutnya tetap kompatibel dengan pengecekan $user->can().
Tabel Role dan Pivot role_user #
Skema berikut memisahkan role menjadi tabel sendiri dan mengizinkan satu user memegang banyak role lewat pivot role_user:
Schema::create('roles', function (Blueprint $table) {
$table->id();
$table->string('name')->unique();
$table->timestamps();
});
Schema::create('role_user', function (Blueprint $table) {
$table->foreignId('role_id')->constrained()->cascadeOnDelete();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->primary(['role_id', 'user_id']);
});Primary key gabungan di pivot mencegah role yang sama menempel dua kali pada satu user. Di sisi model, User membanggakan relasi belongsToMany beserta helper pengecekan. Biaya perpindahan ini terbayar dengan fleksibilitas: menambah role baru cukup satu INSERT, menggabungkan peran pada satu orang berjalan alami, dan tabel roles bisa menyimpan kolom tambahan seperti label atau deskripsi untuk kebutuhan UI.
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
public function roles(): BelongsToMany
{
return $this->belongsToMany(Role::class);
}
public function hasRole(string $name): bool
{
return $this->roles()->where('name', $name)->exists();
}Tabel Permission #
Role menjawab “kelompok apa”, permission menjawab “hak apa”. Kita tambahkan tabel permissions plus pivot permission_role — pola strukturnya sama seperti roles dan role_user:
Schema::create('permissions', function (Blueprint $table) {
$table->id();
$table->string('name')->unique();
$table->timestamps();
});
Schema::create('permission_role', function (Blueprint $table) {
$table->foreignId('permission_id')->constrained()->cascadeOnDelete();
$table->foreignId('role_id')->constrained()->cascadeOnDelete();
$table->primary(['permission_id', 'role_id']);
});Nama permission kita samakan dengan nama ability — posts.create, posts.delete — supaya pemeriksaan $user->can('posts.create') terasa alami. Pengecekannya menjalar dari user ke role lalu ke permission:
public function hasPermission(string $name): bool
{
return $this->roles()
->whereHas('permissions', fn ($query) => $query->where('name', $name))
->exists();
}Menghubungkan Permission ke Gate #
Inilah jembatan yang menyatukan semuanya. Kita daftarkan interceptor Gate::before yang menanyakan tabel permission setiap ada ability diperiksa:
use App\Models\Permission;
use App\Models\User;
use Illuminate\Support\Facades\Gate;
Gate::before(function (User $user, string $ability): bool|null {
if (! Permission::query()->where('name', $ability)->exists()) {
return null; // bukan permission yang dikenal — lanjut ke gate/policy
}
return $user->hasPermission($ability);
});Karena berjalan di before, pengecekan permission menyingkir sebelum policy atau gate lain dievaluasi; mengembalikan null membuat kemampuan yang bukan permission tetap jatuh ke policy seperti biasa. Mari kita uji dengan kombinasi nyata: Siti memegang role editor yang membawa permission posts.create dan posts.delete:
$budi = User::create([
'name' => 'Budi', 'email' => '[email protected]',
'password' => 'password-rahasia', 'role' => 'penulis',
]);
$super = User::create([
'name' => 'Super Admin', 'email' => '[email protected]',
'password' => 'password-rahasia', 'role' => 'super-admin',
]);
$editor = Role::create(['name' => 'editor']);
$buatPost = Permission::create(['name' => 'posts.create']);
$hapusPost = Permission::create(['name' => 'posts.delete']);
$editor->permissions()->attach([$buatPost->id, $hapusPost->id]);
$siti = User::create([
'name' => 'Siti', 'email' => '[email protected]',
'password' => 'password-rahasia', 'role' => 'penulis',
]);
$siti->roles()->attach($editor->id);
echo 'siti->hasRole("editor") : '.var_export($siti->hasRole('editor'), true)."\n";
echo 'siti->can("posts.create") : '.var_export($siti->can('posts.create'), true)."\n";
echo 'siti->can("posts.delete") : '.var_export($siti->can('posts.delete'), true)."\n";
echo 'budi->can("posts.create") : '.var_export($budi->can('posts.create'), true)."\n";
echo 'siti->can("posts.publish"): '.var_export($siti->can('posts.publish'), true)."\n";
echo 'super->can("posts.delete"): '.var_export($super->can('posts.delete'), true)."\n";Output yang Dihasilkan:
siti->hasRole("editor") : true
siti->can("posts.create") : true
siti->can("posts.delete") : true
budi->can("posts.create") : false
siti->can("posts.publish"): false
super->can("posts.delete"): trueSiti lolos persis pada permission yang melekat pada role-nya, Budi yang tak punya role ditolak, dan ability posts.publish yang tidak terdaftar jatuh ke policy tanpa efek samping. Baris terakhir membuktikan Gate::before super-admin dari chapter sebelumnya tetap bekerja di atas keduanya.
Karena permission kini menjadi ability, route bisa mengklaimnya lewat middleware can::
Route::get('/posts/create', fn () => response()->json(['form' => 'Tulis Post']))
->middleware('can:posts.create')
->name('posts.create');Feature test mengonfirmasi ujung ke ujung:
echo 'editor (posts.create) GET /posts/create status: '
.$this->actingAs($siti)->get('/posts/create')->status()."\n";
echo 'penulis (tanpa permission) GET /posts/create status: '
.$this->actingAs($budi)->get('/posts/create')->status()."\n";Output yang Dihasilkan:
editor (posts.create) GET /posts/create status: 200
penulis (tanpa permission) GET /posts/create status: 403Dengan role dan permission berjalan, tiga lapisan otorisasi kita lengkap: policy menjaga kepemilikan, permission menjaga hak, dan gate menjembatani keduanya. Chapter berikutnya memperlihatkan cara memanggil semua itu dari controller dengan tiga gaya berbeda beserta konsekuensi masing-masing.