Cara Buat Login Multi User dengan Laravel

| | | Laravel
Cara Buat Login Multi User dengan Laravel

jujur aja, momen paling bikin keder waktu belajar Laravel itu pas ketemu Policy dan Gate. Spatie-nya aja belum paham, eh sudah disuruh paham “hakim” baru yang namanya Gate. saya ngalamin sendiri: tutorial CRUD + role yang saya ikutin waktu itu langsung lompat ke Policy, dan saya cuma bisa copy-paste tanpa ngerti siapa nolak siapa.

makanya di panduan ini kita pakai jalan yang lebih jujur buat pemula: Spatie tetap dipakai sebagai tempat nyimpan role dan permission, tapi semua keputusan “siapa boleh apa” ditulis langsung di controller pakai if + abort(403). hasilnya satu file UserController.php yang kalau dibaca dari atas ke bawah, semua aturannya kelihatan. kalau kalian sudah baca belajar CRUD PHP MySQL dari nol sampai online yang pakai wajibAdmin() di PHP native, artikel ini adalah versi Laravel-nya: wajibAdmin() diganti hasRole('admin') + abort(403), kolom role diganti tabel Spatie.

satu kalimat yang perlu dihafal sebelum mulai: Spatie itu lemarinya (menyimpan role), controller itu satpam sekaligus hakimnya (mengecek + menolak).

Daftar Isi

1. Konsep Dasar: Lemari vs Satpam

bayangin kos-kosan. Spatie itu lemari arsipnya: di situ tercatat siapa penghuni kamar mana (role) dan kunci apa aja yang dipegang tiap penghuni (permission). Controller itu satpamnya: tiap ada yang mau masuk kamar orang, satpam buka lemari, cek catatannya, terus mutusin boleh atau ditolak.

flowchart LR
    A["Browser<br/>klik + form"] -- "request" --> B["Controller<br/>if hasRole/can<br/>abort 403 kalau ditolak"]
    B -- "cek + tempel role" --> C[("Tabel Spatie<br/>roles + permissions<br/>model_has_roles")]
    B -- "simpan / baca" --> D[("MySQL<br/>tabel users")]
    B -- "HTML / redirect" --> A

    style A fill:#eef4ff,stroke:#000,stroke-width:3px
    style B fill:#f0eefc,stroke:#000,stroke-width:3px
    style C fill:#e6f7fb,stroke:#000,stroke-width:3px
    style D fill:#fff7e6,stroke:#000,stroke-width:3px

di semua method controller demo ini polanya selalu sama, tiga langkah berurutan:

  1. Cek izin: hasRole() atau can() (datanya dari lemari Spatie). tidak lolos → abort(403).
  2. Validasi: $request->validate(). gagal → balik ke form + pesan merah per field.
  3. Simpan: create() / save() / delete(), plus assignRole() / syncRoles() kalau urusan role.

kenapa tidak pakai Policy/Gate sekalian? biar kalian cuma buka 1 file buat lihat semua aturan. kalau if izin mulai menumpuk dan satu aturan dipakai di banyak method, itulah saatnya naik kelas ke Policy — dan kabar baiknya semua method Spatie di sini (hasRole, can, assignRole) tetap sama, cuma pindah rumah. konsep middleware-nya sendiri sudah saya bahas tuntas di Laravel middleware.

hasil akhirnya begini aturannya:

FiturAturan
Register / Login / Logoutbawaan laravel/ui, pendaftar otomatis jadi user
Lihat dataadmin semua baris, user biasa cuma baris miliknya
Tambah dan hapuscuma admin, selain itu 403
Editadmin semua boleh, user biasa cuma milik sendiri
Kolom rolecuma pemilik permission users.assign-role (= admin)

2. Persiapan + Install Package

butuh PHP 8.3+, Composer, dan project Laravel 13 yang sudah bisa php artisan serve. tidak perlu npm sama sekali, karena Bootstrap dimuat via CDN.

composer require spatie/laravel-permission
composer require laravel/ui

baris pertama ngunduh package Spatie (lemarinya). baris kedua ngunduh laravel/ui buat scaffolding auth Bootstrap. lanjut publish + migrate + scaffolding auth:

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

Penjelasan Detail Per Baris install

BarisKodeArtinya + alasannya
1composer require spatie/laravel-permissionngunduh package role/permission paling populer di Laravel (+ auto-register provider). versi yang saya pakai 8.3.0, cek punya kalian pakai composer show spatie/laravel-permission.
2composer require laravel/uingunduh scaffolding auth resmi (controller login/register + view Bootstrap). kenapa yang resmi? biar login + throttle-nya sudah teruji, kita tinggal modif seperlunya.
3vendor:publish --provider="Spatie\..."nyalin dua hal ke project: config/permission.php (jangan diutak-atik, guard default web) dan file migrasi tabel Spatie (jangan edit manual). wajib jalan SEBELUM migrate, kalau tidak tabelnya tidak ada.
4php artisan migratebikin 5 tabel Spatie: roles, permissions, model_has_roles, model_has_permissions, role_has_permissions.
5php artisan ui bootstrap --authngasilin LoginController, RegisterController, view auth/*, dan route auth sekaligus.

3. Migrasi: Kolom Alamat dan No HP

file bawaan Laravel tidak punya kolom alamat dan nomor HP, jadi tambah satu migrasi:

<?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::table('users', function (Blueprint $table) {
            $table->string('alamat')->nullable()->after('email');
            $table->string('no_hp', 20)->nullable()->after('alamat');
        });
    }

    public function down(): void
    {
        Schema::table('users', function (Blueprint $table) {
            $table->dropColumn(['alamat', 'no_hp']);
        });
    }
};

Penjelasan Detail Per Baris migrasi

BarisKodeArtinya + alasannya
1tiga baris use (Migration, Blueprint, Schema)impor class wajib tiap file migrasi: Migration buat bungkusnya, Blueprint buat definisi kolom, Schema buat eksekusi ke database.
2Schema::table('users', ...)ubah tabel yang SUDAH ada (bukan bikin baru, makanya table bukan create).
3$table->string('alamat')->nullable()->after('email')kolom teks boleh kosong, ditaruh habis kolom email biar urutan kolom di database rapi. nullable biar form boleh dikosongin.
4$table->string('no_hp', 20)->nullable()max 20 karakter, realistis buat nomor HP. kenapa string bukan integer? karena nomor HP bisa diawali 0 atau +, kalau integer nol depannya hilang.
5method down() + dropColumnkebalikan up(), jalan pas rollback. migrate dan rollback harus selalu simetris biar database bisa maju-mundur dengan aman.
6yang SENGAJA tidak ada: kolom rolerole tidak disimpan di tabel users, melainkan di tabel Spatie. satu sumber kebenaran, tidak ada data ganda yang bisa beda isi.

4. Model User: Tempel Trait HasRoles

<?php

namespace App\Models;

use Database\Factories\UserFactory;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Attributes\Hidden;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Spatie\Permission\Traits\HasRoles;

#[Fillable(['name', 'email', 'alamat', 'no_hp', 'password'])]
#[Hidden(['password', 'remember_token'])]
class User extends Authenticatable
{
    /** @use HasFactory<UserFactory> */
    use HasFactory, Notifiable, HasRoles;

    protected function casts(): array
    {
        return [
            'email_verified_at' => 'datetime',
            'password' => 'hashed',
        ];
    }

    public function isAdmin(): bool
    {
        return $this->hasRole('admin');
    }
}

Penjelasan Detail Per Baris User.php

BarisKodeArtinya + alasannya
1use Spatie\Permission\Traits\HasRoles;baris kuncinya. trait Spatie itu semacam “sambungan” yang ditempel ke model biar bisa ngomong sama tabel Spatie. tanpa ini, hasRole() error Call to undefined method.
2#[Fillable(['name', ..., 'password'])]daftar kolom yang boleh diisi massal lewat User::create(). perhatiin role TIDAK ada: role ditempel lewat method Spatie (assignRole/syncRoles), bukan mass-assignment.
3#[Hidden(['password', 'remember_token'])]kolom yang disembunyiin pas model diubah ke JSON. biar password tidak bocor di response.
4use HasFactory, Notifiable, HasRoles;tiga trait yang dipakai: factory buat testing, notifikasi bawaan, dan Spatie. trait terakhir yang ngasih hasRole(), assignRole(), syncRoles(), getRoleNames(), can().
5'password' => 'hashed' di casts()tiap nilai yang dipasang ke password otomatis di-hash Laravel. dampaknya gede: di controller cukup tulis password polos, JANGAN Hash::make manual (kalau manual malah ke-hash dua kali).
6isAdmin() return $this->hasRole('admin')helper mungil biar bacanya enak. isinya nanya ke tabel Spatie, bukan ke kolom (kolomnya emang tidak ada).

cara ngebuktiin relasinya jalan:

php artisan tinker --execute='$u=App\Models\User::where("email","[email protected]")->first(); var_dump($u->getRoleNames()->toArray()); var_dump($u->can("users.assign-role"));'

harus keluar ["admin"] dan true. versi user: ["user"] dan false.

5. Seeder: Isi Lemari + 2 Akun Demo

seeder kerja 3 langkah berurutan yang JANGAN dibalik: bikin permission dulu, tempel ke role, tempel role ke user.

    public function run(): void
    {
        // Spatie me-cache permission — reset dulu agar seed selalu segar.
        app(PermissionRegistrar::class)->forgetCachedPermissions();

        // 1. Permission: "boleh melakukan APA".
        $permissions = [
            'users.view',
            'users.create',
            'users.update',
            'users.delete',
            'users.assign-role',
        ];

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

        // 2. Role: "SIAPA" + tempeli permission-nya.
        $adminRole = Role::firstOrCreate(['name' => 'admin', 'guard_name' => 'web']);
        $adminRole->syncPermissions($permissions);

        $userRole = Role::firstOrCreate(['name' => 'user', 'guard_name' => 'web']);
        $userRole->syncPermissions(['users.view', 'users.update']);

        // 3. Akun demo + tempel role-nya.
        $admin = User::updateOrCreate(
            ['email' => '[email protected]'],
            [
                'name' => 'Admin Demo',
                'alamat' => 'Jl. Admin No. 1',
                'no_hp' => '081234567890',
                'password' => 'password',
            ]
        );
        $admin->syncRoles(['admin']);

        $user = User::updateOrCreate(
            ['email' => '[email protected]'],
            [
                'name' => 'User Demo',
                'alamat' => 'Jl. User No. 2',
                'no_hp' => '089876543210',
                'password' => 'password',
            ]
        );
        $user->syncRoles(['user']);
    }

Penjelasan Detail Per Baris DatabaseSeeder.php

BlokKodeArtinya + alasannya
cacheforgetCachedPermissions()Spatie nyimpen cache permission biar can() tidak query terus. habis seed tanpa reset, datanya bisa basi (diubah tapi efeknya tidak berubah). makanya reset dulu.
1lima permission + firstOrCreatepermission = “boleh melakukan APA”, unit izin terkecil. users.assign-role yang paling spesial: khusus ganti role, cuma ditempel ke admin. firstOrCreate biar db:seed aman diulang tanpa data ganda. guard_name = 'web' karena login kita lewat session web.
2syncPermissions($permissions) buat adminrumusnya: permission ditempel ke role. admin dikawinin ke lima-limanya. sync = sinkron total, yang tidak ada di daftar dibuang.
2syncPermissions(['users.view', 'users.update']) buat useruser cuma boleh lihat + edit. tidak boleh tambah, hapus, apalagi ganti role. granular gini yang tidak bisa dilakuin kolom role string biasa.
3updateOrCreate(['email' => ...], [...])cari by email, ketemu di-update, tidak ketemu dibikin. idempotent kayak firstOrCreate. 'password' => 'password' ditulis polos karena cast hashed yang nge-hash otomatis.
3$admin->syncRoles(['admin'])role ditempel ke user. nulisnya ke tabel pivot model_has_roles, bukan ke kolom users.

jalankan php artisan db:seed, terus login pakai [email protected] / password atau [email protected] / password.

6. Auth: Register Otomatis Jadi User

LoginController bawaan cuma diubah satu baris: $redirectTo dari /home jadi /dashboard. logic login + throttle-nya tetap milik trait AuthenticatesUsers, tidak usah ditulis ulang.

RegisterController dimodif dua method:

    protected function validator(array $data)
    {
        return Validator::make($data, [
            'name' => ['required', 'string', 'max:255'],
            'email' => ['required', 'string', 'email', 'max:255', 'unique:users'],
            'alamat' => ['nullable', 'string', 'max:255'],
            'no_hp' => ['nullable', 'string', 'max:20'],
            'password' => ['required', 'string', 'min:8', 'confirmed'],
        ]);
    }

    protected function create(array $data)
    {
        $user = User::create([
            'name' => $data['name'],
            'email' => $data['email'],
            'alamat' => $data['alamat'] ?? null,
            'no_hp' => $data['no_hp'] ?? null,
            'password' => $data['password'],
        ]);

        $user->assignRole('user');

        return $user;
    }

Penjelasan Detail Per Baris RegisterController.php

BlokKodeArtinya + alasannya
validatortambah alamat + no_hp nullablengikutin kolom baru di migrasi. nullable biar form register tidak maksa ngisi.
validatorTIDAK ada aturan roleini penting: form publik tidak punya input role. pendaftar mandiri selalu jadi user, sama kayak $role = 'user'; hardcode di tutorial CRUD PHP native. kalau role bisa dipilih dari form, semua orang daftar jadi admin.
createUser::create([...]) tanpa roleuser dibikin dulu TANPA role, soalnya kolomnya emang tidak ada.
create$user->assignRole('user')satu baris ini yang bikin pendaftar otomatis punya permission users.view + users.update. assignRole = tempel tanpa ngapus yang lama (beda sama syncRoles yang ganti total). karena method ini ngabaikan input role apa pun, pendaftar yang nyelipin role=admin manual tetap jadi user. ada test otomatisnya di bab 11.

7. UserController: index, create, store

nah ini jantungnya. constructor dulu:

class UserController extends Controller
{
    public function __construct()
    {
        // Semua method di sini wajib login dulu. Belum login → dilempar
        // ke halaman /login otomatis oleh middleware 'auth'.
        $this->middleware('auth');
    }

satu baris $this->middleware('auth') masang satpam pintu ke semua method. tamu belum login dilempar ke /login sebelum nyentuh method mana pun. detail konsepnya ada di panduan middleware.

    public function index(): View
    {
        $currentUser = auth()->user();

        if (! $currentUser->can('users.view')) {
            abort(403, 'Kamu tidak boleh melihat data user.');
        }

        $users = $currentUser->hasRole('admin')
            ? User::with('roles')->latest()->paginate(10)
            : User::with('roles')->where('id', $currentUser->id)->paginate(10);

        return view('users.index', compact('users'));
    }

    public function create(): View
    {
        if (! auth()->user()->hasRole('admin')) {
            abort(403, 'Hanya admin yang boleh menambah user.');
        }

        return view('users.create');
    }

Penjelasan Detail Per Baris index() + create()

BarisKodeArtinya + alasannya
1$currentUser = auth()->user()ambil user yang lagi login dari session. Laravel yang ngisinya otomatis, kita tidak pernah ngoper manual. PSR-nya: nama variabel deskriptif bahasa Inggris, bukan $saya.
2if (! $currentUser->can('users.view')) abort(403, ...)nanya ke tabel Spatie: punya permission lihat nggak? role baru yang suatu hari dibikin tanpa permission ini otomatis kena 403. abort(403) = berhenti + tampil halaman “dilarang”.
3ternary hasRole('admin') ? ... : ...admin dapat semua user, user biasa cuma baris miliknya via where('id', ...). with('roles') muat relasi role sekaligus biar tidak kena N+1 query. paginate(10) = 10 baris per halaman.
4create() cuma hasRole('admin') atau 403form tambah pintunya satu baris. ingat: sembunyiin tombol di Blade saja tidak cukup, orang bisa ketik /users/create manual. server harus nolak juga.
    public function store(Request $request): RedirectResponse
    {
        if (! auth()->user()->hasRole('admin')) {
            abort(403, 'Hanya admin yang boleh menambah user.');
        }

        $data = $request->validate([
            'name' => ['required', 'string', 'max:255'],
            'email' => ['required', 'string', 'email', 'max:255', 'unique:users,email'],
            'alamat' => ['nullable', 'string', 'max:255'],
            'no_hp' => ['nullable', 'string', 'max:20'],
            'password' => ['required', 'string', 'min:8', 'confirmed'],
            'role' => ['required', 'in:admin,user'],
        ]);

        $user = User::create([
            'name' => $data['name'],
            'email' => $data['email'],
            'alamat' => $data['alamat'] ?? null,
            'no_hp' => $data['no_hp'] ?? null,
            'password' => $data['password'],
        ]);

        $user->assignRole($data['role']);

        return redirect()->route('users.index')->with('sukses', 'User berhasil ditambahkan.');
    }

Penjelasan Detail Per Baris store()

BlokKodeArtinya + alasannya
cekhasRole('admin') atau 403sama kayak create(). form dan proses submit dijaga aturan identik, jangan cuma salah satunya.
validasi$request->validate([...])gagal otomatis balik ke form + error per field (@error di Blade). tidak perlu Form Request terpisah buat CRUD segede ini, satu file lebih gampang dijelasin.
validasi'role' => ['required', 'in:admin,user']karena cuma admin yang sampai sini, role selalu wajib diisi dan cuma dua pilihan. whitelist in: nolak role=superadmin kiriman DevTools.
simpanUser::create() TANPA role + assignRole()data disimpan dulu, role ditempel ke tabel Spatie sesudahnya. urutannya tidak boleh dibalik: assignRole butuh user yang sudah ada id-nya.
outputredirect()->route('users.index')->with('sukses', ...)pola redirect after POST: biar refresh tidak ngirim dobel. pesan sukses dibaca layout jadi alert hijau.

8. UserController: edit, update, destroy

    public function edit(User $user): View
    {
        $currentUser = auth()->user();

        if (! $currentUser->can('users.update')) {
            abort(403, 'Kamu tidak boleh mengedit data user.');
        }

        if (! $currentUser->hasRole('admin') && $currentUser->id !== $user->id) {
            abort(403, 'Kamu hanya boleh mengedit data milikmu sendiri.');
        }

        return view('users.edit', compact('user'));
    }

dua lapis cek: permission dulu, kepemilikan kemudian. parameter User $user itu Route Model Binding — Laravel otomatis ngubah {user} di URL jadi object user (ID ngawur langsung 404 sebelum sampai ke cek). admin lolos lapis kedua karena hasRole('admin') true; user biasa cuma lolos kalau id-nya sama dengan target.

    public function update(Request $request, User $user): RedirectResponse
    {
        $currentUser = auth()->user();

        if (! $currentUser->can('users.update')) {
            abort(403, 'Kamu tidak boleh mengedit data user.');
        }

        if (! $currentUser->hasRole('admin') && $currentUser->id !== $user->id) {
            abort(403, 'Kamu hanya boleh mengedit data milikmu sendiri.');
        }

        $aturanRole = $currentUser->can('users.assign-role')
            ? ['required', 'in:admin,user']
            : ['prohibited'];

        $data = $request->validate([
            'name' => ['required', 'string', 'max:255'],
            'email' => ['required', 'string', 'email', 'max:255', Rule::unique('users')->ignore($user->id)],
            'alamat' => ['nullable', 'string', 'max:255'],
            'no_hp' => ['nullable', 'string', 'max:20'],
            'password' => ['nullable', 'string', 'min:8', 'confirmed'],
            'role' => $aturanRole,
        ], [
            'role.prohibited' => 'Kamu tidak boleh mengubah role.',
        ]);

        $user->fill([
            'name' => $data['name'],
            'email' => $data['email'],
            'alamat' => $data['alamat'] ?? null,
            'no_hp' => $data['no_hp'] ?? null,
        ]);

        if (! empty($data['password'])) {
            $user->password = $data['password'];
        }

        $user->save();

        if ($currentUser->can('users.assign-role')) {
            $roleBaru = $data['role'];

            if ($user->hasRole('admin') && $roleBaru !== 'admin') {
                if ($user->id === $currentUser->id) {
                    return back()->withErrors(['role' => 'Tidak bisa menurunkan role akunmu sendiri.'])->withInput();
                }

                if (User::role('admin')->count() <= 1) {
                    return back()->withErrors(['role' => 'Tidak bisa: ini admin terakhir.'])->withInput();
                }
            }

            $user->syncRoles([$roleBaru]);
        }

        return redirect()->route('users.index')->with('sukses', 'Data user berhasil diperbarui.');
    }

Penjelasan Detail Per Baris update()

BlokKodeArtinya + alasannya
cekdua if sama persis kayak edit()nampilin form dan memproses submit dijaga aturan identik. konsistensi gini disengaja biar tidak ada celah: form dijaga, prosesnya los.
kunci$aturanRole = can('users.assign-role') ? required : prohibitedblok TERPENTING se-tutorial. admin wajib isi role, non-admin DILARANG ngirim field role. user yang nyelipin role=admin via DevTools langsung gagal validasi + role-nya tetap. ini versi Laravel dari $role = $user['role']; (dikunci) di edit.php tutorial PHP native.
validasiRule::unique('users')->ignore($user->id)edit tanpa ganti email harus lolos. tanpa ignore, email sendiri dianggap duplikat. sama persis kayak cek id <> ? di tutorial native.
validasi'password' => ['nullable', ...]dikosongin = tidak ganti. makanya di bawah ada if (! empty(...)) sebelum ditimpa. biar password tidak ke-reset jadi kosong gara-gara user tidak niat ganti.
simpanfill() + password kondisional + save()fill ngisi kolom biasa tanpa nyimpan dulu, password cuma ditimpa kalau diisi, save nulis ke database.
roleif ($currentUser->can('users.assign-role'))pertahanan lapis ketiga: syncRoles() (buang role lama, ganti total) cuma jalan buat admin. dua proteksi di dalamnya: tidak boleh nurunin role diri sendiri, dan admin terakhir tidak boleh diturunin. User::role('admin')->count() itu scope Spatie buat ngitung sisa admin.
    public function destroy(User $user): RedirectResponse
    {
        $currentUser = auth()->user();

        if (! $currentUser->can('users.delete')) {
            abort(403, 'Hanya admin yang boleh menghapus user.');
        }

        if ($currentUser->id === $user->id) {
            abort(403, 'Tidak boleh menghapus akunmu sendiri.');
        }

        if ($user->hasRole('admin') && User::role('admin')->count() <= 1) {
            abort(403, 'Tidak boleh menghapus admin terakhir.');
        }

        $user->delete();

        return redirect()->route('users.index')->with('sukses', 'User berhasil dihapus.');
    }

Penjelasan Detail Per Baris destroy()

BarisKodeArtinya + alasannya
1can('users.delete') atau 403permission hapus cuma ditempel ke admin di seeder, jadi cek ini sekaligus jadi pintu admin. user biasa yang nekat kirim DELETE manual langsung mental.
2tolak hapus diri sendirikalau admin hapus akunnya yang lagi dipakai, session jadi yatim dan tidak ada admin tersisa. sama kayak aturan di hapus.php tutorial native.
3tolak hapus admin terakhir$user->hasRole('admin') (targetnya admin?) + User::role('admin')->count() <= 1 (sisanya tinggal satu?). dua-duanya true berarti sistem bakal kehilangan admin.
4$user->delete() + redirect sukseshuruf D di CRUD. baru jalan kalau tiga cek di atas lolos semua.

9. Routes: Peta URL Tanpa Middleware Role

use App\Http\Controllers\UserController;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Route;

Route::get('/', function () {
    return auth()->check()
        ? redirect()->route('dashboard')
        : redirect()->route('login');
});

Auth::routes();

Route::middleware('auth')->group(function () {
    Route::redirect('/home', '/dashboard');

    Route::get('/dashboard', function () {
        return view('dashboard');
    })->name('dashboard');

    Route::get('/users', [UserController::class, 'index'])->name('users.index');
    Route::get('/users/create', [UserController::class, 'create'])->name('users.create');
    Route::post('/users', [UserController::class, 'store'])->name('users.store');
    Route::get('/users/{user}/edit', [UserController::class, 'edit'])->name('users.edit');
    Route::put('/users/{user}', [UserController::class, 'update'])->name('users.update');
    Route::delete('/users/{user}', [UserController::class, 'destroy'])->name('users.destroy');
});

Penjelasan Detail Per Baris web.php

BarisKodeArtinya + alasannya
1route / redirect gandatamu → login, yang sudah login → dashboard. satu pintu masuk, dua tujuan.
2Auth::routes()daftarin semua route auth bawaan laravel/ui (login, register, logout, reset password) sekaligus. jangan ditulis manual satu per satu.
3Route::middleware('auth')->group(...)semua di dalam grup wajib login. cuma middleware ini yang dipakai — SENGAJA tanpa role:admin, penjaganya if di controller biar semua keputusan izin ada di satu tempat.
4Route::redirect('/home', '/dashboard')/home bawaan ui dialihin ke dashboard biar cuma ada satu halaman utama (HomeController bawaan sudah dihapus).
5enam route CRUD eksplisitditulis satu per satu, BUKAN Route::resource, biar pemula lihat jelas pasangan URL + method + nama route. {user} otomatis jadi object via Route Model Binding.
6cek php artisan route:listharus 23 route, tanpa nama ganda. kalau ada error class not found, biasanya urutan bikin file kebalik (controller dibikin sebelum model).

10. Views: Tabel, Form, dan Badge Role

layout pakai Bootstrap 5 via CDN, jadi murni PHP tanpa npm install:

<link href="https://cdn.jsdelivr.net/npm/[email protected]/dist/css/bootstrap.min.css" rel="stylesheet">

satu-satunya syarat: ada internet pas halaman dibuka. navbar nampilin menu cuma buat yang login (@auth), badge role di samping nama user, dan alert hijau buat pesan session('sukses'):

@if (auth()->user()->hasRole('admin'))
    <span class="badge text-bg-warning">admin</span>
@else
    <span class="badge text-bg-secondary">{{ auth()->user()->getRoleNames()->first() ?? 'user' }}</span>
@endif

tabel user (users/index) punya tiga pola @if:

{{-- Tombol tambah: kosmetik, penjaga asli di controller --}}
@if (auth()->user()->hasRole('admin'))
    <a href="{{ route('users.create') }}" class="btn btn-primary btn-sm">Tambah User Baru</a>
@endif

{{-- Badge role per baris: tampil DATA, bukan kolom (kolomnya tidak ada!) --}}
@if ($item->hasRole('admin'))
    <span class="badge text-bg-warning">admin</span>
@else
    <span class="badge text-bg-secondary">{{ $item->getRoleNames()->first() }}</span>
@endif

{{-- Tombol edit: admin semua baris, user biasa cuma miliknya --}}
@if (auth()->user()->hasRole('admin') || auth()->id() === $item->id)
    <a href="{{ route('users.edit', $item) }}" class="btn btn-sm btn-warning">Edit</a>
@endif

{{-- Tombol hapus: cuma admin dan bukan baris miliknya sendiri --}}
@if (auth()->user()->hasRole('admin') && auth()->id() !== $item->id)
    <form action="{{ route('users.destroy', $item) }}" method="POST" class="d-inline" onsubmit="return confirm('Yakin hapus user ini?')">
        @csrf
        @method('DELETE')
        <button type="submit" class="btn btn-sm btn-danger">Hapus</button>
    </form>
@endif

Penjelasan Detail Per Baris views tabel

BarisKodeArtinya + alasannya
1@if hasRole('admin') buat tombol tambahkosmetik biar rapi. keamanan aslinya di UserController@create yang abort 403. dua-duanya dipasang, kayak tombol + wajibAdmin() di tutorial native.
2badge via getRoleNames()->first()nampilin DATA role dari tabel Spatie. BUKAN $item->role, kolom itu tidak ada, ditulis gitu langsung error.
3tombol edit hasRole OR id samacerminan aturan controller: admin semua baris, user cuma miliknya.
4@csrf + @method('DELETE')token keamanan wajib tiap form Laravel, dan trik biar form HTML (cuma bisa GET/POST) kebaca sebagai DELETE oleh route.
5onsubmit="return confirm(...)"rem tangan browser biar tidak kepencet, hapus itu permanen.

form tambah (users/create) itu form Bootstrap standar + @error per field + dropdown role dengan old() biar pilihan tidak hilang pas validasi gagal:

<select id="role" name="role" class="form-select @error('role') is-invalid @enderror" required>
    <option value="">-- Pilih role --</option>
    <option value="admin" {{ old('role') === 'admin' ? 'selected' : '' }}>admin</option>
    <option value="user" {{ old('role') === 'user' ? 'selected' : '' }}>user</option>
</select>

form edit (users/edit) beda tiga hal: action ke users.update + @method('PUT'), input pakai old('name', $user->name) (nilai database jadi default, input terakhir pas error jadi prioritas), password berlabel “Password Baru” yang boleh dikosongin, dan dropdown role DIBUNGKUS @if admin:

{{-- Dropdown role CUMA tampil buat admin (kosmetik).
    Keamanan aslinya: update() nolak field role non-admin via 'prohibited'. --}}
@if (auth()->user()->hasRole('admin'))
    <select id="role" name="role" class="form-select @error('role') is-invalid @enderror" required>
        <option value="admin" {{ old('role', $user->getRoleNames()->first()) === 'admin' ? 'selected' : '' }}>admin</option>
        <option value="user" {{ old('role', $user->getRoleNames()->first()) === 'user' ? 'selected' : '' }}>user</option>
    </select>
@endif

nilai terpilih diambil dari $user->getRoleNames()->first() (tabel Spatie). validasi semua Blade lolos dengan php artisan view:cache.

detail kecil yang gampang kelupaan: AppServiceProvider perlu satu baris biar pagination ikut gaya Bootstrap, bukan Tailwind default:

use Illuminate\Pagination\Paginator;

    public function boot(): void
    {
        Paginator::useBootstrapFive();
    }

11. Test: Bukti Selip Role Ditolak

test pakai SQLite memory, jadi RefreshDatabase ngapus + migrasi ulang SEMUA tabel (termasuk tabel Spatie) tiap test. akibatnya setUp() WAJIB bikin ulang role + permission sebelum assignRole(), kalau tidak error There is no role named 'admin':

    protected function setUp(): void
    {
        parent::setUp();

        foreach (['users.view', 'users.create', 'users.update', 'users.delete', 'users.assign-role'] as $name) {
            Permission::firstOrCreate(['name' => $name, 'guard_name' => 'web']);
        }

        Role::firstOrCreate(['name' => 'admin', 'guard_name' => 'web'])
            ->syncPermissions(['users.view', 'users.create', 'users.update', 'users.delete', 'users.assign-role']);
        Role::firstOrCreate(['name' => 'user', 'guard_name' => 'web'])
            ->syncPermissions(['users.view', 'users.update']);

        $this->admin = User::factory()->create(['email' => '[email protected]']);
        $this->admin->assignRole('admin');

        $this->user = User::factory()->create(['email' => '[email protected]']);
        $this->user->assignRole('user');
    }

dua test yang paling layak dipahami:

    public function test_user_biasa_bisa_edit_data_sendiri_tapi_tidak_bisa_ubah_role(): void
    {
        // Edit nama sendiri = boleh.
        $this->actingAs($this->user)
            ->put("/users/{$this->user->id}", [
                'name' => 'Nama Baru',
                'email' => '[email protected]',
            ])
            ->assertRedirect('/users');

        // Menyelipkan role=admin = DITOLAK validasi (aturan prohibited),
        // dan role tetap 'user'.
        $this->actingAs($this->user)
            ->put("/users/{$this->user->id}", [
                'name' => 'Nama Baru',
                'email' => '[email protected]',
                'role' => 'admin',
            ])
            ->assertSessionHasErrors('role');

        $this->assertTrue($this->user->fresh()->hasRole('user'));
        $this->assertFalse($this->user->fresh()->hasRole('admin'));
    }

    public function test_register_dengan_role_admin_tetap_jadi_user(): void
    {
        $this->post('/register', [
            'name' => 'Pendaftar Nekat',
            'email' => '[email protected]',
            'password' => 'password123',
            'password_confirmation' => 'password123',
            'role' => 'admin', // ← diselipkan manual, harus diabaikan
        ]);

        $pendaftar = User::where('email', '[email protected]')->first();
        $this->assertNotNull($pendaftar);
        $this->assertTrue($pendaftar->hasRole('user'));
        $this->assertFalse($pendaftar->hasRole('admin'));
    }

Penjelasan Detail Per Baris test kunci

BarisKodeArtinya + alasannya
1actingAs($this->user)pura-pura login sebagai user biasa. semua request di bawah jalan sebagai dia.
2PUT tanpa field role → assertRedirectedit nama sendiri itu boleh, buktinya redirect sukses ke /users.
3PUT nyelipin role=admin → assertSessionHasErrors('role')inilah bukti aturan prohibited jalan: request nakal gagal validasi tepat di field role.
4fresh()->hasRole('user') true + hasRole('admin') falsefresh() baca ulang dari database (bukan dari memori), buktinya role beneran tidak berubah.
5POST /register bawa role=adminnyerang pintu publik. hasilnya pendaftar tetap user, karena create() ngabaikan input role.

total 14 test (php artisan test), semuanya hijau: 12 test akses + 2 bawaan. jalankan pakai php artisan test --filter=UserAccessTest kalau mau fokus.

12. Jalan di Laptop + Deploy

cd laravel
composer install
php artisan migrate --seed
php artisan serve   # buka http://127.0.0.1:8000

--seed itu WAJIB, bukan opsional. tanpa seeder tabel Spatie kosong → semua user tidak punya users.view → halaman /users 403 buat semua orang termasuk admin. kalau habis deploy role terasa “tidak berubah”, itu cache Spatie basi: php artisan permission:cache-reset, atau kalau ragu php artisan optimize:clear.

skenario demo buat video: daftar user baru (otomatis jadi user), login admin buat tambah/edit/hapus, login user buat buktiin /users/create mental 403 dan edit milik orang mental 403, selipin role=admin via DevTools buat lihat error “Kamu tidak boleh mengubah role.”, terus buktiin di tinker kalau getRoleNames() dan can('users.assign-role') beda hasil buat admin vs user.

buat deploy, PHP butuh hosting PHP + MySQL (Vercel dan GitHub Pages tidak bisa, cuma bisa file statis). opsi gratis: InfinityFree (PHP 8.4 + MySQL + SSL gratis). polanya: bikin database di panel, upload file, sesuaikan .env (host-nya BUKAN localhost, bentuknya kayak sql123.infinityfree.com), terus migrate + seed TANPA SSH lewat route sementara yang langsung dihapus habis dipakai. detail langkahnya sudah saya tulis di PANDUAN-DEMO.md demo ini (bab 6-7: cPanel dan InfinityFree).

13. Error Paling Sering + Best Practice

Pesan / GejalaPenyebabSolusi 1 menit
There is no role named 'admin'role belum di-seed (lupa db:seed), atau test pakai database lain yang kosong.jalanin php artisan db:seed; di test bikin role di setUp() kayak bab 11.
GuardDoesNotMatchrole dibikin guard api tapi login guard web (atau sebaliknya).samain guard_name ('web' di demo ini) pas firstOrCreate.
Call to undefined method ... hasRoletrait HasRoles belum dipasang di model.tambahin use HasRoles; di User.php (bab 4).
role diubah tapi halaman tidak berubahcache Spatie basi.php artisan permission:cache-reset (lokal) / hapus storage/framework/cache/data/* (server tanpa SSH).
semua halaman /users 403 termasuk admintabel Spatie kosong (lupa --seed).jalanin php artisan db:seed, seeder idempotent jadi aman diulang.
edit tanpa ganti email malah “email sudah dipakai”unique tanpa ignore.pakai Rule::unique('users')->ignore($user->id) kayak di update().
password ke-reset jadi kosongpassword ikut fill() padahal dikosongin.cuma timpa kalau ! empty($data['password']), kayak di update().
could not find driver (sqlite) pas testekstensi sqlite mati di php.ini.aktifin extension=pdo_sqlite + extension=sqlite3, restart terminal.

best practice versi industri (mulai biasain dari sekarang):

  • jangan percaya input user. dropdown role bisa diubah dari DevTools, makanya ada whitelist in:admin,user + aturan prohibited + cek can() di controller. tiga lapis, kayak KTP diperiksa di gerbang, resepsionis, arsip.
  • password selalu hash otomatis. cast hashed di model, jangan Hash::make manual di controller (malah ke-hash dua kali).
  • otorisasi di server, bukan di tombol. sembunyiin tombol itu UX, abort(403) itu keamanan. dua-duanya dipasang.
  • variabel bahasa Inggris deskriptif. $currentUser bukan $saya, $aturanRole/$roleBaru camelCase. biar yang baca (termasuk kalian 6 bulan lagi) langsung paham.
  • redirect habis POST sukses. biar refresh tidak ngirim dobel. gagal? tampilin lagi formnya + error-nya.
  • proteksi admin terakhir. sistem tanpa admin = sistem mati. cek User::role('admin')->count() sebelum hapus atau turunin role.

kalau mau naik level habis ini: Laravel Spatie roles permissions buat versi middleware + @role-nya, cara membuat login multi user php buat pendalaman role di PHP native, dan membuat REST API dengan Laravel kalau mau bawa konsep izin ini ke API.

14. Kesimpulan

kita baru aja bangun CRUD user Laravel beneran: daftar yang otomatis jadi user, login yang tahu kamu siapa, tabel yang beda isi buat admin dan user, tambah khusus admin, edit yang tahu siapa boleh ubah siapa, hapus yang tidak boleh bunuh diri, sampai role yang tidak bisa dicuri lewat DevTools. polanya selalu sama: cek izin → validasi → simpan.

kalau pola ini sudah nempel di kepala, ganti tabel users jadi siswa, barang, atau anggota tinggal ganti nama kolom dan label formnya aja. mesinnya sama. dan kalau suatu hari if izin mulai menumpuk, pindahin ke Policy: method Spatie-nya tidak berubah, cuma pindah rumah.

coba sekarang: daftar satu akun baru, login sebagai admin, ubah role-nya, edit datanya, hapus, logout. terus selipin role=admin sebagai user dan lihat ditolaknya. kalau semua lancar tanpa lihat contekan, berarti kalian sudah lulus modul ini. happy coding!

Pertanyaan yang Sering Diajukan

Apa itu spatie/laravel-permission?

Package Laravel paling populer untuk mengelola role dan permission. Ia menyimpan relasi user-role-permission di tabel database dan memberi helper seperti hasRole(), assignRole(), syncRoles(), dan can() lewat trait HasRoles.

Bisakah pakai Spatie tanpa Policy dan Gate?

Bisa. Spatie hanya mengurus penyimpanan dan pengecekan role/permission, sedangkan keputusan izin boleh ditulis langsung di controller dengan if + abort(403). Cocok untuk CRUD sederhana dan buat belajar sebelum naik ke Policy.

Di mana role user disimpan kalau tidak ada kolom role?

Di tabel pivot model_has_roles milik Spatie, bukan di tabel users. Satu baris di tabel itu menyatakan user mana memegang role apa, dicek lewat $user->hasRole() atau $user->getRoleNames().

Bagaimana cara mencegah user biasa mengubah role miliknya sendiri?

Tiga lapis: sembunyikan dropdown role di Blade untuk non-admin, tolak field role lewat aturan validasi prohibited di controller, dan hanya jalankan syncRoles() bila pengirim punya permission users.assign-role.

Kapan harus pindah dari cek di controller ke Policy dan Gate?

Saat if izin mulai menumpuk dan satu aturan dipakai di banyak method. Policy memindahkan semua aturan ke satu class khusus tanpa mengubah method Spatie yang dipakai, jadi migrasi-nya mulus.

Sigit Nurhanafi avatar
Full-stack developer & technical writer. Berpengalaman di PHP, Laravel, NodeJS, MySQL, dan Python. Aktif menulis tutorial pemrograman dan maintaining open-source projects di PemburuKode sejak 2021.