Skema dari Bagian 1 sudah berdiri; sekarang giliran kode PHP yang mengisi tabel tersebut lewat alur pertama setiap sistem autentikasi: registrasi. Tugasnya terdengar sederhana — terima email, kata sandi, dan nama, lalu simpan akun baru — tetapi urutan pengerjaannya menentukan dua hal yang tidak bisa ditawar: kata sandi hanya boleh hidup sebagai hash bcrypt, dan penolakan duplikat harus memakai pesan yang ramah, bukan galat SQL mentah. Pada bagian ini kita membangun dua class, UserRepository sebagai satu pintu akses data dan RegistrationService sebagai otak alurnya, lalu membuktikan keduanya lewat walkthrough penuh di database :memory:.

Alur Registrasi yang Aman #

Sebelum menulis class, kita tetapkan urutan kerja registrasi secara eksplisit, karena urutan inilah yang paling sering salah:

  1. Validasi masukan — nama wajib terisi, format email sah, kata sandi minimal 8 karakter.
  2. Normalisasi — spasi dibuang, email dikecilkan kapitalnya agar tersimpan dalam satu bentuk baku.
  3. Pemeriksaan email — cari email di database; bila sudah ada, tolak sekarang juga.
  4. Hash kata sandi — baru pada titik ini password_hash() dijalankan.
  5. Penyimpanan — insert lewat prepared statement; constraint UNIQUE tetap berjaga di belakang.

Alasan pemeriksaan email diletakkan sebelum hashing adalah biaya. bcrypt memang sengaja lambat — pada PHP 8.4 dengan cost default 12, satu panggilan password_hash() memakan waktu sekitar 200 milidetik. Bila pengecekan duplikat dilakukan setelah hashing, setiap percobaan mendaftar dengan email yang sudah dipakai membakar 200 milidetik CPU server untuk hasil yang sudah jelas ditolak. Penyerang pun bisa memakai halaman registrasi sebagai penguras CPU; urutan di atas menutup celah tersebut dengan menolak duplikat dalam waktu kurang dari satu milidetik.

Normalisasi email ke huruf kecil melengkapi COLLATE NOCASE dari Bagian 1. Collation menjadikan [email protected] dan [email protected] dianggap sama oleh database, sedangkan normalisasi memastikan email tersimpan dalam satu bentuk baku sehingga laporan dan pengiriman surat elektronik tidak perlu menebak kapital mana yang dipakai pengguna saat mendaftar.

Class UserRepository #

Seluruh query SQL proyek ini dipusatkan di satu class repository, pola yang sudah kita bangun pada chapter Repository Pattern . Berikut isi lengkap src/UserRepository.php sebagaimana tersedia pada berkas sumber di bagian bawah halaman:

php
<?php

declare(strict_types=1);

/**
 * Lapisan akses data untuk tabel users.
 *
 * Seluruh query SQL sistem autentikasi dipusatkan di class ini.
 * Koneksi PDO disuntikkan lewat constructor, sehingga pemanggil-lah
 * yang menentukan database yang dipakai: berkas di disk untuk
 * aplikasi sungguhan, atau `sqlite::memory:` untuk demo dan test.
 */
final class UserRepository
{
    public function __construct(private PDO $pdo)
    {
    }

    /**
     * Cari satu pengguna berdasarkan email; null bila tidak ditemukan.
     *
     * Cocok nama kolom memakai COLLATE NOCASE, sehingga "[email protected]"
     * dan "[email protected]" dianggap email yang sama.
     *
     * @return array<string, mixed>|null
     */
    public function cariEmail(string $email): ?array
    {
        $stmt = $this->pdo->prepare(
            'SELECT id, email, password_hash, nama, email_verified_at, dibuat_pada
               FROM users
              WHERE email = ?'
        );
        $stmt->execute([$email]);
        $baris = $stmt->fetch(PDO::FETCH_ASSOC);

        return $baris === false ? null : $baris;
    }

    /**
     * Cari satu pengguna berdasarkan id; null bila tidak ditemukan.
     *
     * Dipakai untuk memuat ulang pengguna dari user_id yang tersimpan
     * di session pada Bagian 3.
     *
     * @return array<string, mixed>|null
     */
    public function cariId(int $id): ?array
    {
        $stmt = $this->pdo->prepare(
            'SELECT id, email, password_hash, nama, email_verified_at, dibuat_pada
               FROM users
              WHERE id = ?'
        );
        $stmt->execute([$id]);
        $baris = $stmt->fetch(PDO::FETCH_ASSOC);

        return $baris === false ? null : $baris;
    }

    /**
     * Simpan pengguna baru, kembalikan id baris yang baru dibuat.
     */
    public function insert(string $email, string $passwordHash, string $nama): int
    {
        $stmt = $this->pdo->prepare(
            'INSERT INTO users (email, password_hash, nama) VALUES (?, ?, ?)'
        );
        $stmt->execute([$email, $passwordHash, $nama]);

        return (int) $this->pdo->lastInsertId();
    }

    /**
     * Perbarui password_hash satu pengguna.
     *
     * Dipakai oleh alur reset kata sandi pada Bagian 5.
     * Mengembalikan false bila id tidak ditemukan.
     */
    public function updateHash(int $id, string $passwordHash): bool
    {
        $stmt = $this->pdo->prepare(
            'UPDATE users SET password_hash = ? WHERE id = ?'
        );
        $stmt->execute([$passwordHash, $id]);

        return $stmt->rowCount() > 0;
    }
}

Beberapa keputusan pada class tersebut perlu dibedah satu per satu:

  1. __construct(private PDO $pdo) memakai constructor property promotion: koneksi masuk sebagai dependency dari pemanggil. Aplikasi sungguhan menyuntikkan koneksi ke berkas .sqlite di disk, sementara demo dan test menyuntikkan sqlite::memory: yang musnah sendiri — repository tidak peduli bedanya.
  2. cariEmail() memakai parameter binding untuk nilai dari luar sesuai materi Prepared Statements , dan menerjemahkan false dari fetch() menjadi null agar kontrak return type ?array bersih. Berkat COLLATE NOCASE pada kolom email, pencarian dengan kapital apa pun menemukan baris yang sama.
  3. cariId() terlihat belum terpakai di bagian ini — memang begitu. Method ini disiapkan untuk Bagian 3, yang memuat ulang pengguna berdasarkan user_id di session. Menaruhnya sejak awal di satu pintu data mencegah query menyebar ke class lain.
  4. insert() menerima hash sebagai string jadi, bukan kata sandi mentah. Repository tidak tahu dan tidak perlu tahu cara meng-hash — tanggung jawab itu ada di layanan; dan lastInsertId() dipandu cast (int) karena PDO mengembalikannya sebagai string.
  5. updateHash() membaca rowCount() sebagai sinyal: lebih dari nol berarti baris terpengaruh, nol berarti id tidak ditemukan. Method ini baru dipakai alur reset kata sandi pada Bagian 5, tetapi kontraknya sudah teruji di sini.

Class RegistrationService #

Repository tahu cara menyimpan, tetapi keputusan ada di layanan. Berikut isi lengkap src/RegistrationService.php:

php
<?php

declare(strict_types=1);

/**
 * Exception ramah pengguna: email sudah dipakai akun lain.
 */
final class EmailSudahDipakaiException extends RuntimeException
{
}

/**
 * Exception ramah pengguna: data registrasi tidak lolos validasi.
 */
final class DataRegistrasiTidakValidException extends RuntimeException
{
}

/**
 * Layanan registrasi pengguna.
 *
 * Urutan kerja register(): validasi -> normalisasi -> cek email ->
 * hash kata sandi -> insert. Hash dikerjakan paling akhir sebelum
 * insert karena bcrypt mahal; tidak ada gunanya membakar ~200 ms
 * CPU untuk data yang sudah jelas ditolak validasi atau duplikat.
 */
final class RegistrationService
{
    public function __construct(private UserRepository $users)
    {
    }

    /**
     * Daftarkan pengguna baru; kembalikan baris pengguna lengkap.
     *
     * @param string $email    alamat email mentah dari pengguna
     * @param string $password kata sandi mentah dari pengguna
     * @param string $nama     nama lengkap pengguna
     *
     * @return array<string, mixed> baris users hasil insert
     *
     * @throws DataRegistrasiTidakValidException bila validasi gagal
     * @throws EmailSudahDipakaiException        bila email sudah terdaftar
     */
    public function register(string $email, string $password, string $nama): array
    {
        $email = $this->validasiDanNormalisasi($email, $password, $nama);

        // Cek email sebelum hashing agar percobaan duplikat murah.
        if ($this->users->cariEmail($email) !== null) {
            throw new EmailSudahDipakaiException(
                "Email $email sudah terdaftar. Gunakan email lain atau masuk dengan email ini."
            );
        }

        $hash = password_hash($password, PASSWORD_BCRYPT);

        try {
            $id = $this->users->insert($email, $hash, $nama);
        } catch (PDOException $e) {
            // Pemeriksaan cariEmail() di atas bisa kalah cepat dari
            // registrasi paralel (race condition); constraint UNIQUE
            // pada skema tetap menjadi jaminan terakhir.
            if ($e->getCode() === '23000') {
                throw new EmailSudahDipakaiException(
                    "Email $email sudah terdaftar. Gunakan email lain atau masuk dengan email ini.",
                    0,
                    $e
                );
            }

            throw $e;
        }

        return $this->users->cariId($id);
    }

    /**
     * Validasi masukan lalu kembalikan email yang sudah dinormalkan.
     *
     * @throws DataRegistrasiTidakValidException
     */
    private function validasiDanNormalisasi(string $email, string $password, string $nama): string
    {
        $nama = trim($nama);
        $email = strtolower(trim($email));

        if ($nama === '') {
            throw new DataRegistrasiTidakValidException('Nama wajib diisi.');
        }

        if (strlen($nama) > 100) {
            throw new DataRegistrasiTidakValidException('Nama tidak boleh lebih dari 100 karakter.');
        }

        if (filter_var($email, FILTER_VALIDATE_EMAIL) === false) {
            throw new DataRegistrasiTidakValidException('Format email tidak valid.');
        }

        if (strlen($password) < 8) {
            throw new DataRegistrasiTidakValidException('Kata sandi minimal 8 karakter.');
        }

        return $email;
    }
}

Ada enam keputusan penting pada layanan tersebut:

  1. Dua exception terpisah — EmailSudahDipakaiException dan DataRegistrasiTidakValidException — membuat pemanggil bisa membedakan “ulangi dengan data lain” dari “perbaiki isian form”. Keduanya extends RuntimeException mengikuti pola chapter Custom Exceptions , dan pesannya berbahasa manusia karena memang ditujukan untuk pengguna akhir.
  2. register() mengembalikan baris pengguna lengkap hasil cariId() setelah insert, sehingga pemanggil langsung menerima id dan data akun tanpa query tambahan.
  3. Pemeriksaan $this->users->cariEmail($email) !== null memberikan penolakan cepat untuk duplikat. Pemeriksaan ini bukan jaminan akhir — dua request paralel bisa sama-sama lolos pemeriksaan sebelum salah satunya sempat insert.
  4. catch (PDOException $e) dengan kode 23000 adalah jaminan akhirnya: bila race condition terjadi, constraint UNIQUE dari Bagian 1 menolak insert kedua, dan layanan menerjemahkannya menjadi exception yang sama ramahnya. PDOException asli disertakan sebagai $e (parameter ketiga) sehingga log developer tetap bisa melihat penyebab teknisnya.
  5. password_hash($password, PASSWORD_BCRYPT) baru dijalankan setelah semua pemeriksaan lolos. String hash lalu dikirim ke insert() sebagai nilai parameter — hash tidak pernah digabung ke string SQL, tidak pernah dicetak, dan tidak pernah masuk log.
  6. strlen($password) dihitung per byte, bukan mb_strlen(), karena kebijakan panjang kata sandi memang diukur dalam byte — cara penghitungan yang sama dipakai server mana pun; sedangkan strtolower() pada email aman dipakai karena alamat email hanya memuat karakter ASCII.

Walkthrough Registrasi Sukses #

Sekarang kita jalankan alur lengkapnya. Skrip berikut mendaftarkan Tono dengan email yang kapitalnya tidak rapi, lalu mendaftarkan pengguna kedua dengan kata sandi yang persis sama:

php
<?php
require __DIR__ . '/src/UserRepository.php';
require __DIR__ . '/src/RegistrationService.php';

$pdo = new PDO('sqlite::memory:');
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$pdo->exec(file_get_contents(__DIR__ . '/../bagian-1-user-schema/schema.sql'));

$repo = new UserRepository($pdo);
$registrasi = new RegistrationService($repo);

$user = $registrasi->register('[email protected]', 'sandi-tono-2026', 'Tono Wijaya');

echo "Registrasi sukses:\n";
echo "  id                : {$user['id']}\n";
echo "  email             : {$user['email']}\n";
echo "  nama              : {$user['nama']}\n";
echo '  email_verified_at : ' . var_export($user['email_verified_at'], true) . "\n";
echo '  hash              : panjang=' . strlen($user['password_hash'])
    . ' awalan=' . substr($user['password_hash'], 0, 4) . "\n";
echo '  verify sandi-tono-2026  : ' . var_export(password_verify('sandi-tono-2026', $user['password_hash']), true) . "\n";
echo '  verify sandi-salah      : ' . var_export(password_verify('sandi-salah', $user['password_hash']), true) . "\n";

$userB = $registrasi->register('[email protected]', 'sandi-tono-2026', 'Rina Melati');
echo "\nRegistrasi kedua dengan sandi yang sama:\n";
echo '  hash identik dengan user pertama : ' . var_export(
    $user['password_hash'] === $userB['password_hash'],
    true
) . "\n";
echo '  verify terhadap user kedua       : ' . var_export(
    password_verify('sandi-tono-2026', $userB['password_hash']),
    true
) . "\n";

Output yang Dihasilkan:

text
Registrasi sukses:
  id                : 4
  email             : [email protected]
  nama              : Tono Wijaya
  email_verified_at : NULL
  hash              : panjang=60 awalan=$2y$
  verify sandi-tono-2026  : true
  verify sandi-salah      : false

Registrasi kedua dengan sandi yang sama:
  hash identik dengan user pertama : false
  verify terhadap user kedua       : true

Empat hal pada output tersebut layak dicermati. Pertama, email tersimpan sebagai [email protected] meskipun pengguna mengetik [email protected] — bukti normalisasi bekerja. Kedua, email_verified_at bernilai NULL karena akun baru memang belum terverifikasi. Ketiga, hash yang dikembalikan register() lolos pemeriksaan password_verify() untuk kata sandi yang benar dan gagal untuk yang salah, dan kita hanya melihat fakta strukturalnya: panjang 60, awalan $2y$. Keempat, Rina mendaftar dengan kata sandi yang persis sama dengan Tono, tetapi hash identik bernilai false — salt acak dari Bagian 1 bekerja, sehingga dua akun dengan kata sandi sama tidak menyimpan hash yang sama, dan kebocoran satu hash tidak membocorkan kata sandi akun lain.

Menolak Duplikat dan Masukan Tidak Valid #

Sisi lain alur adalah penolakan. Lima percobaan berikut sengaja menyentuh semua jalur gagal: duplikat persis, duplikat beda kapital, format email salah, kata sandi pendek, dan nama kosong:

php
<?php
require __DIR__ . '/src/UserRepository.php';
require __DIR__ . '/src/RegistrationService.php';

$pdo = new PDO('sqlite::memory:');
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$pdo->exec(file_get_contents(__DIR__ . '/../bagian-1-user-schema/schema.sql'));

$registrasi = new RegistrationService(new UserRepository($pdo));

$percobaan = [
    ['[email protected]',   'sandi-apapun-2026', 'Budi Kedua'],   // duplikat persis
    ['[email protected]',   'sandi-apapun-2026', 'Siti Kedua'],   // duplikat beda kapital
    ['bukan-email',        'sandi-valid-2026',  'Pengguna X'],   // format email salah
    ['[email protected]', 'pendek',            'Pengguna Y'],   // sandi terlalu pendek
    ['[email protected]', 'sandi-valid-2026',  '   '],          // nama kosong
];

foreach ($percobaan as [$email, $sandi, $nama]) {
    try {
        $registrasi->register($email, $sandi, $nama);
        echo "TERSIMPAN (seharusnya tidak): $email\n";
    } catch (EmailSudahDipakaiException $e) {
        echo "EmailSudahDipakaiException        : {$e->getMessage()}\n";
    } catch (DataRegistrasiTidakValidException $e) {
        echo "DataRegistrasiTidakValidException : {$e->getMessage()}\n";
    }
}

$jumlah = $pdo->query('SELECT COUNT(*) FROM users')->fetchColumn();
echo "\nJumlah pengguna setelah lima percobaan ditolak: $jumlah\n";

Output yang Dihasilkan:

text
EmailSudahDipakaiException        : Email [email protected] sudah terdaftar. Gunakan email lain atau masuk dengan email ini.
EmailSudahDipakaiException        : Email [email protected] sudah terdaftar. Gunakan email lain atau masuk dengan email ini.
DataRegistrasiTidakValidException : Format email tidak valid.
DataRegistrasiTidakValidException : Kata sandi minimal 8 karakter.
DataRegistrasiTidakValidException : Nama wajib diisi.

Jumlah pengguna setelah lima percobaan ditolak: 3

Seluruh percobaan ditolak dengan pesan yang bisa langsung dipajankan di form. Perhatikan kasus kedua: email diketik [email protected], tetapi pesan penolakan menyebut [email protected] — normalisasi berjalan sebelum pemeriksaan, sehingga pengguna melihat bentuk baku email yang sudah dipakai. Urutan validasi juga terlihat dari kasus keempat dan kelima: kata sandi pendek ditolak lebih dulu daripada nama kosong karena pemeriksaan nama memang didahulukan. Paling penting, baris terakhir menegaskan jumlah pengguna tetap tiga: tidak ada satu pun baris lolos ke tabel, dan seluruh penolakan terjadi tanpa satu panggilan password_hash() pun terbuang.

Cara Menjalankan Contoh #

Contoh pada bagian ini membutuhkan schema.sql dari Bagian 1 serta dua berkas class dari bagian ini. Susun foldernya sebagai berikut:

text
sistem-autentikasi/
├── bagian-1-user-schema/
│   └── schema.sql
└── bagian-2-registrasi-dan-password-hashing/
    ├── src/
    │   ├── UserRepository.php
    │   └── RegistrationService.php
    └── uji-registrasi.php

Simpan salah satu walkthrough di atas sebagai uji-registrasi.php di dalam folder bagian-2-registrasi-dan-password-hashing, lalu jalankan:

bash
php uji-registrasi.php

Path __DIR__ . '/../bagian-1-user-schema/schema.sql' menunjuk satu tingkat ke atas lalu masuk ke folder bagian saudara, sehingga struktur folder ini harus dipertahankan. Seluruh contoh memakai :memory:, jadi tidak ada berkas database yang tersisa setelah eksekusi.

Apa yang Berubah #

Dibandingkan Bagian 1 yang hanya memiliki skema, proyek kini memiliki lapisan aplikasi pertama yang hidup:

  • Berkas src/UserRepository.php baru berisi class final dengan empat operasi data: cariEmail(), cariId(), insert(), dan updateHash() — seluruhnya lewat prepared statements dengan PDO yang disuntikkan.
  • Berkas src/RegistrationService.php baru mengubah alur registrasi menjadi method register() dengan urutan validasi → normalisasi → cek email → hash → insert.
  • Penolakan duplikat kini punya dua lapis: pemeriksaan cepat lewat cariEmail() dan jaminan akhir lewat penerjemahan kode SQLSTATE 23000 menjadi EmailSudahDipakaiException.
  • Dua exception ramah pengguna didefinisikan, dan tidak ada satu pun jalur kode yang mencetak hash mentah atau pesan SQL mentah.

Ringkasan #

Registrasi kini berjalan di atas fondasi yang benar: masukan divalidasi dan dinormalkan lebih dulu, email duplikat ditolak murah sebelum bcrypt dinyalakan, kata sandi hanya pernah hidup sebagai hash 60 karakter awalan $2y$, dan constraint database tetap berjaga terhadap race condition. Tabel users sudah bisa diisi dengan aman, dan baris-baris hasil registrasi itulah yang akan diperiksa setiap kali seseorang mencoba masuk. Pada Bagian 3 kita membangun AuthService — layanan login yang memakai password_verify(), pesan galat generik, dan session dengan id yang diregenerasi.

Referensi #

Berkas Sumber

Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.

src/RegistrationService.php

Unduh berkas
php
<?php

declare(strict_types=1);

/**
 * Exception ramah pengguna: email sudah dipakai akun lain.
 */
final class EmailSudahDipakaiException extends RuntimeException
{
}

/**
 * Exception ramah pengguna: data registrasi tidak lolos validasi.
 */
final class DataRegistrasiTidakValidException extends RuntimeException
{
}

/**
 * Layanan registrasi pengguna.
 *
 * Urutan kerja register(): validasi -> normalisasi -> cek email ->
 * hash kata sandi -> insert. Hash dikerjakan paling akhir sebelum
 * insert karena bcrypt mahal; tidak ada gunanya membakar ~200 ms
 * CPU untuk data yang sudah jelas ditolak validasi atau duplikat.
 */
final class RegistrationService
{
    public function __construct(private UserRepository $users)
    {
    }

    /**
     * Daftarkan pengguna baru; kembalikan baris pengguna lengkap.
     *
     * @param string $email    alamat email mentah dari pengguna
     * @param string $password kata sandi mentah dari pengguna
     * @param string $nama     nama lengkap pengguna
     *
     * @return array<string, mixed> baris users hasil insert
     *
     * @throws DataRegistrasiTidakValidException bila validasi gagal
     * @throws EmailSudahDipakaiException        bila email sudah terdaftar
     */
    public function register(string $email, string $password, string $nama): array
    {
        $email = $this->validasiDanNormalisasi($email, $password, $nama);

        // Cek email sebelum hashing agar percobaan duplikat murah.
        if ($this->users->cariEmail($email) !== null) {
            throw new EmailSudahDipakaiException(
                "Email $email sudah terdaftar. Gunakan email lain atau masuk dengan email ini."
            );
        }

        $hash = password_hash($password, PASSWORD_BCRYPT);

        try {
            $id = $this->users->insert($email, $hash, $nama);
        } catch (PDOException $e) {
            // Pemeriksaan cariEmail() di atas bisa kalah cepat dari
            // registrasi paralel (race condition); constraint UNIQUE
            // pada skema tetap menjadi jaminan terakhir.
            if ($e->getCode() === '23000') {
                throw new EmailSudahDipakaiException(
                    "Email $email sudah terdaftar. Gunakan email lain atau masuk dengan email ini.",
                    0,
                    $e
                );
            }

            throw $e;
        }

        return $this->users->cariId($id);
    }

    /**
     * Validasi masukan lalu kembalikan email yang sudah dinormalkan.
     *
     * @throws DataRegistrasiTidakValidException
     */
    private function validasiDanNormalisasi(string $email, string $password, string $nama): string
    {
        $nama = trim($nama);
        $email = strtolower(trim($email));

        if ($nama === '') {
            throw new DataRegistrasiTidakValidException('Nama wajib diisi.');
        }

        if (strlen($nama) > 100) {
            throw new DataRegistrasiTidakValidException('Nama tidak boleh lebih dari 100 karakter.');
        }

        if (filter_var($email, FILTER_VALIDATE_EMAIL) === false) {
            throw new DataRegistrasiTidakValidException('Format email tidak valid.');
        }

        if (strlen($password) < 8) {
            throw new DataRegistrasiTidakValidException('Kata sandi minimal 8 karakter.');
        }

        return $email;
    }
}

src/UserRepository.php

Unduh berkas
php
<?php

declare(strict_types=1);

/**
 * Lapisan akses data untuk tabel users.
 *
 * Seluruh query SQL sistem autentikasi dipusatkan di class ini.
 * Koneksi PDO disuntikkan lewat constructor, sehingga pemanggil-lah
 * yang menentukan database yang dipakai: berkas di disk untuk
 * aplikasi sungguhan, atau `sqlite::memory:` untuk demo dan test.
 */
final class UserRepository
{
    public function __construct(private PDO $pdo)
    {
    }

    /**
     * Cari satu pengguna berdasarkan email; null bila tidak ditemukan.
     *
     * Cocok nama kolom memakai COLLATE NOCASE, sehingga "[email protected]"
     * dan "[email protected]" dianggap email yang sama.
     *
     * @return array<string, mixed>|null
     */
    public function cariEmail(string $email): ?array
    {
        $stmt = $this->pdo->prepare(
            'SELECT id, email, password_hash, nama, email_verified_at, dibuat_pada
               FROM users
              WHERE email = ?'
        );
        $stmt->execute([$email]);
        $baris = $stmt->fetch(PDO::FETCH_ASSOC);

        return $baris === false ? null : $baris;
    }

    /**
     * Cari satu pengguna berdasarkan id; null bila tidak ditemukan.
     *
     * Dipakai untuk memuat ulang pengguna dari user_id yang tersimpan
     * di session pada Bagian 3.
     *
     * @return array<string, mixed>|null
     */
    public function cariId(int $id): ?array
    {
        $stmt = $this->pdo->prepare(
            'SELECT id, email, password_hash, nama, email_verified_at, dibuat_pada
               FROM users
              WHERE id = ?'
        );
        $stmt->execute([$id]);
        $baris = $stmt->fetch(PDO::FETCH_ASSOC);

        return $baris === false ? null : $baris;
    }

    /**
     * Simpan pengguna baru, kembalikan id baris yang baru dibuat.
     */
    public function insert(string $email, string $passwordHash, string $nama): int
    {
        $stmt = $this->pdo->prepare(
            'INSERT INTO users (email, password_hash, nama) VALUES (?, ?, ?)'
        );
        $stmt->execute([$email, $passwordHash, $nama]);

        return (int) $this->pdo->lastInsertId();
    }

    /**
     * Perbarui password_hash satu pengguna.
     *
     * Dipakai oleh alur reset kata sandi pada Bagian 5.
     * Mengembalikan false bila id tidak ditemukan.
     */
    public function updateHash(int $id, string $passwordHash): bool
    {
        $stmt = $this->pdo->prepare(
            'UPDATE users SET password_hash = ? WHERE id = ?'
        );
        $stmt->execute([$passwordHash, $id]);

        return $stmt->rowCount() > 0;
    }
}