Registrasi yang hanya meminta email dan password tidak menjamin email tersebut benar-benar milik pendaftar. Seseorang bisa mendaftar memakai email orang lain dan menyalahgunakan akun hasilnya. Email verification menutup celah ini: setelah registrasi, akun ditandai belum terverifikasi dan Laravel mengirim email berisi link khusus; hanya pemilik kotak masuk email yang bisa mengekliknya. Selama email belum diverifikasi, area sensitif aplikasi kita kunci. Laravel menyediakan seluruh perangkatnya — dari interface di model hingga middleware verified — sehingga kita cukup merangkai bagiannya.
Mengaktifkan MustVerifyEmail di Model User #
Semuanya bermula dari satu perubahan di app/Models/User.php: implementasikan interface MustVerifyEmail dan pakai trait yang menyediakan implementasinya:
use Illuminate\Auth\MustVerifyEmail;
use Illuminate\Contracts\Auth\MustVerifyEmail as MustVerifyEmailContract;
use Illuminate\Foundation\Auth\User as Authenticatable;
class User extends Authenticatable implements MustVerifyEmailContract
{
use HasFactory, MustVerifyEmail, Notifiable;
// ...
}Trait MustVerifyEmail memberi empat method siap pakai ke model: hasVerifiedEmail(), markEmailAsVerified(), getEmailForVerification(), dan sendEmailVerificationNotification(). Status verifikasi disimpan di kolom email_verified_at — kolom bertipe timestamp nullable yang sudah tersedia di migrasi bawaan tabel users. Selama nilainya null, pengguna dianggap belum terverifikasi; begitu terisi, verifikasi selesai.
Interface ini juga menjadi penanda yang dibaca middleware verified: hanya user yang instanceof MustVerifyEmail yang bisa diblokir. Setelah registrasi, kita sendiri yang memanggil $user->sendEmailVerificationNotification() dari controller registrasi untuk mengirim notifikasi Illuminate\Auth\Notifications\VerifyEmail berisi link verifikasi.
Route Verifikasi dan URL Bertanda Tangan #
Tiga route membentuk kerangka verifikasi email:
Route::get('/email/verify', [AuthController::class, 'verifikasiNotice'])
->name('verification.notice')->middleware('auth');
Route::get('/email/verify/{id}/{hash}', [AuthController::class, 'verifikasiVerify'])
->middleware(['signed', 'throttle:6,1'])->name('verification.verify');
Route::post('/email/resend', [AuthController::class, 'verifikasiResend'])
->middleware(['auth', 'throttle:6,1'])->name('verification.send');Route pertama adalah halaman pemberitahuan “silakan verifikasi email Anda”. Route kedua adalah tujuan link di email — perhatikan middleware signed dan parameter {id}/{hash}. Link verifikasi adalah signed URL: URL yang dibubuhi parameter expires dan signature, di mana signature adalah HMAC dari seluruh URL (path, parameter, termasuk expires) yang dihitung memakai APP_KEY. Berikut contoh URL yang dihasilkan URL::temporarySignedRoute() (waktu kita bekukan agar tandatangannya deterministik):
http://localhost:8000/email/verify/7/d2ff43bcf8f0d2465ad90dbb8c0cd0dd0fd8cbe2?expires=1768039200&signature=1fa9e75f2e389cec32d833eb5437da4c402a8e283d34c62c01872b61815f3c6bMiddleware signed menolak URL yang tanda tangannya tidak cocok atau sudah kedaluwarsa. Middleware throttle:6,1 membatasi enam request per menit agar pengirim ulang link tidak bisa disalahgunakan untuk mengirim spam.
Melindungi Route dengan Middleware verified #
Sekarang bagian yang paling terasa oleh pengguna. Kita tambahkan middleware verified di samping auth pada area yang butuh email terverifikasi:
Route::middleware(['auth', 'verified'])->group(function () {
Route::get('/dashboard', [AuthController::class, 'dashboard'])->name('dashboard');
});Middleware auth menolak tamu; middleware verified — implementasinya class EnsureEmailIsVerified — menolak pengguna yang sudah login tetapi hasVerifiedEmail() masih false. Penolakan kedua berbentuk redirect ke route verification.notice, dan URL yang semula dituju tersimpan sebagai intended URL sehingga setelah verifikasi pengguna kembali ke halaman semula. Mari kita buktikan keduanya lewat feature test:
public function test_user_belum_verifikasi_diblokir_dari_dashboard(): void
{
$user = User::factory()->unverified()->create([
'name' => 'Budi',
'email' => '[email protected]',
]);
$response = $this->actingAs($user)->get('/dashboard');
$response->assertRedirect(route('verification.notice'));
}
public function test_user_terverifikasi_bisa_masuk_dashboard(): void
{
$user = User::factory()->create([
'name' => 'Siti',
'email' => '[email protected]',
'email_verified_at' => now(),
]);
$this->actingAs($user)->get('/dashboard')->assertOk();
}Output yang Dihasilkan:
belum_verifikasi: status=302, target=http://localhost:8000/email/verify, intended=tersimpan
terverifikasi: status=200, isi="Halo, Siti! Ini dashboard."Perhatikan state unverified() pada factory pertama — factory bawaan justru mengisi email_verified_at dengan waktu sekarang, jadi kita harus eksplisit saat menguji kondisi belum verifikasi.
Menandai Terverifikasi dan Event Verified #
Controller untuk route verification.verify mengerjakan tiga langkah: mencari user, mencocokkan hash dengan email, lalu menandai verifikasi:
public function verifikasiVerify(Request $request, int $id, string $hash)
{
$user = User::findOrFail($id);
if (! hash_equals(sha1($user->getEmailForVerification()), (string) $hash)) {
abort(403, 'Hash verifikasi tidak cocok.');
}
if (! $user->hasVerifiedEmail()) {
$user->markEmailAsVerified();
event(new Verified($user));
}
return redirect()->intended('/dashboard?verified=1');
}Pemeriksaan hash_equals() memastikan hash di URL memang milik email user yang bersangkutan — lapisan kedua di selain tanda tangan URL. markEmailAsVerified() mengisi kolom email_verified_at, dan event Verified dilepas agar listener kita bisa bereaksi (mengirim welcome email, misalnya). Pada aplikasi admin, kita juga bisa memverifikasi manual dari kode: $user->markEmailAsVerified() tanpa email sama sekali. Feature test berikut menguji tiga skenario — klik link sah, klik link yang hash-nya dimanipulasi, dan kirim ulang link sampai kena throttle — dengan Event::fake() aktif agar event tercatat:
$mail = (new VerifyEmail)->toMail($user);
$response = $this->get($mail->actionUrl);
$this->assertTrue($user->fresh()->hasVerifiedEmail());
Event::assertDispatched(Verified::class);Output yang Dihasilkan:
klik_link: status=302, email_verified_at_terisi=ya, event_verified=dispatched
hash_palsu: status=403, email_tetap_belum_verifikasi=ya
resend: pertama=302, ketujuh=429Baris terakhir menunjukkan throttle bekerja: enam permintaan pertama diterima, permintaan ketujuh ditolak dengan status 429.
Email terverifikasi memperkuat identitas pengguna, tetapi identitas itu sendiri masih hilang setiap kali session berakhir. Di chapter Remember Me kita akan membuat login yang bertahan jauh melewati batas waktu session. Rujukan resmi alurnya ada di dokumentasi Email Verification .