Channel mail mengirim lalu lupa; bell icon aplikasi modern butuh yang lain — notifikasi yang tinggal di dalam aplikasi, punya status sudah/belum dibaca, dan bisa dihapus user. Untuk itu ada channel database: setiap pengiriman menulis satu baris ke tabel notifications dengan payload JSON dari toArray(), dan relasi notifications di model User menyediakan seluruh API pembacaan. Pada chapter ini kita migrasi tabelnya, mengirim notifikasi nyata, membongkar isi barisnya lewat query mentah, lalu memutar status baca dan menghapus — semuanya dengan output runner yang bisa dibaca ulang.

Tabel notifications #

Tabel ini tidak dibawa migrasi default. Perintah notifications:table menghasilkan migrasinya:

bash
php artisan notifications:table

Output yang Dihasilkan:

text

   INFO  Migration [database/migrations/2026_10_13_192437_create_notifications_table.php] created successfully.  

Berkas yang dihasilkan memperlihatkan struktur yang khas untuk relasi polymorphic:

php
Schema::create('notifications', function (Blueprint $table) {
    $table->uuid('id')->primary();
    $table->string('type');
    $table->morphs('notifiable');
    $table->text('data');
    $table->timestamp('read_at')->nullable();
    $table->timestamps();
});

Empat kolom menarik untuk dicermati. Kolom id bertipe uuid — bukan auto-increment — karena ID juga dipakai oleh channel lain (misalnya broadcast). Kolom type menyimpan nama class notifikasi (App\Notifications\OrderPaid). Kolom morphs('notifiable') membuat dua kolom notifiable_type + notifiable_id sehingga tabel yang sama bisa memuat notifikasi untuk user, admin, bahkan model Team. Kolom data bertipe text menampung JSON dari toArray(). Setelah php artisan migrate, tabel siap dipakai.

Mengirim dan Memeriksa Barisnya #

Notifikasi OrderPaid dari chapter sebelumnya sudah mengembalikan ['database', 'mail'] dari via(). Pengiriman cukup lewat method notify() di user — method ini disediakan trait Notifiable:

php
use App\Models\User;
use App\Notifications\OrderPaid;

$user = User::where('email', '[email protected]')->firstOrFail();

$user->notify(new OrderPaid('A-01', 10000));
$user->notify(new OrderPaid('A-02', 20000));

Satu panggilan notify() memproses kedua channel: menyimpan baris ke tabel dan mengirim email. Runner membaca kembali kondisi user setelah dua notifikasi:

php
$user = $user->fresh();
echo 'total: ' . $user->notifications->count() . "\n";
echo 'belum_dibaca: ' . $user->unreadNotifications->count() . "\n";

$first = $user->notifications->sortBy('created_at')->first();
$first->markAsRead();

$user = $user->fresh();
echo 'setelah_baca_satu total: ' . $user->notifications->count() . "\n";
echo 'setelah_baca_satu belum_dibaca: ' . $user->unreadNotifications->count() . "\n";

$ids = $user->notifications->sortBy('created_at')
    ->map(fn ($n) => $n->data['order_id'])->implode(', ');
echo 'order_ids: ' . $ids . "\n";

Output yang Dihasilkan:

text
total: 2
belum_dibaca: 2
setelah_baca_satu total: 2
setelah_baca_satu belum_dibaca: 1
order_ids: A-01, A-02

Relasi notifications dan unreadNotifications adalah dua query yang berbeda: yang pertama memfilter read_at IS NULL, yang kedua tanpa filter. Setelah markAsRead() pada notifikasi pertama, total tetap dua (baris tidak hilang) tetapi belum dibaca turun menjadi satu — markAsRead() hanya mengisi kolom read_at dengan waktu sekarang.

Bagaimana isi baris mentahnya? Query langsung ke tabel memperlihatkan seluruh kolom:

php
use Illuminate\Support\Facades\DB;

$row = DB::table('notifications')->orderBy('created_at')->first();
echo 'id: ' . substr($row->id, 0, 8) . '... (uuid)' . "\n";
echo 'type: ' . $row->type . "\n";
echo 'notifiable_type: ' . $row->notifiable_type . "\n";
echo 'notifiable_id: ' . $row->notifiable_id . "\n";
echo 'data: ' . $row->data . "\n";
echo 'read_at: ' . var_export($row->read_at, true) . "\n";

Output yang Dihasilkan:

text
id: cf5f0281... (uuid)
type: App\Notifications\OrderPaid
notifiable_type: App\Models\User
notifiable_id: 2
data: {"order_id":"500","total":99000,"pesan":"Pembayaran diterima."}
read_at: NULL

Kolom data berisi JSON persis dari toArray() — diakses lewat property magis $notification->data['order_id'] karena model DatabaseNotification meng-cast kolom ini ke array.

Menandai Baca dan Membaliknya #

Siklus baca tidak satu arah. Method markAsUnread() mengosongkan read_at kembali:

php
$first = $user->notifications->first();
$first->markAsRead();
echo 'read_at_setelah_markAsRead: ' . $first->fresh()->read_at . "\n";
echo 'belum_dibaca: ' . $user->fresh()->unreadNotifications->count() . "\n";

$first->fresh()->markAsUnread();
echo 'read_at_setelah_markAsUnread: ' . var_export((string) $first->fresh()->read_at, true) . "\n";

Output yang Dihasilkan:

text
read_at_setelah_markAsRead: 2026-10-13 19:28:16
belum_dibaca: 1
read_at_setelah_markAsUnread: ''

Untuk tombol “tandai semua dibaca”, higher-order message each pada collection mengerjakannya satu baris:

php
$user->unreadNotifications->each->markAsRead();

Penghapusan memakai relasi eloquent biasa:

php
$terakhir = $user->notifications->last();
$terakhir->delete();
echo 'total_setelah_delete: ' . $user->fresh()->notifications->count() . "\n";

Output yang Dihasilkan:

text
total_setelah_delete: 1

Route untuk Antarmuka #

Pola route untuk daftar dan aksi baca biasanya menempatkan notifikasi di bawah prefix sendiri:

php
use Illuminate\Support\Facades\Route;

Route::get('/notifikasi', fn () => 'daftar notifikasi')->name('notifications.index');
Route::post('/notifikasi/{id}/read', fn (string $id) => "tandai dibaca: {$id}")
    ->name('notifications.read');

Verifikasi nama route menunjukkan URL yang dihasilkan:

php
echo 'GET  ' . route('notifications.index') . "\n";
echo 'POST ' . route('notifications.read', ['id' => 'abc-123']) . "\n";

Output yang Dihasilkan:

text
GET  http://localhost:8000/notifikasi
POST http://localhost:8000/notifikasi/abc-123/read

Parameter {id} berisi UUID dari kolom id — route-model binding bisa dipasang dengan DatabaseNotification $notification sebagai parameter agar Laravel mencari barisnya otomatis. Di sisi Blade, badge jumlah cukup {{ $user->unreadNotifications->count() }} dan daftarnya loop biasa atas $user->notifications.

Notifikasi Tanpa Model (On-Demand) #

Kadang penerima bukan user terdaftar — pelanggan anonim yang hanya perlu email konfirmasi. Notification::route() membuat penerima anonim dengan alamat per channel:

php
use Illuminate\Support\Facades\Notification;

Notification::route('mail', '[email protected]')
    ->notify(new OrderPaid('999', 25000));

Log membuktikan email terkirim ke alamat tersebut:

text
To: [email protected]
Subject: Pembayaran Pesanan #999 Diterima

Penerima on-demand hanya memperoleh channel yang diberi route — di sini mail saja, tanpa baris database karena tidak ada model untuk dinotifikasi.

Dengan riwayat tersimpan dan status baca berfungsi, tinggal satu sisi yang belum dibedah: format email dari notifikasi itu sendiri. Chapter Mail Notifications membahas builder MailMessage secara penuh. Dokumentasi resminya ada di dokumentasi Notifications .