In Laravel, how do you build an in-app notification bell with the database channel, and how do toDatabase(), toArray() and markAsRead() fit in?
answer
- php artisan make:notifications-table
- data column holds a JSON array
- toDatabase() wins over toArray()
- unreadNotifications relationship
- markAsRead() fills read_at
basics
~20 sCreate the table with make:notifications-table, add 'database' to via() and return an array from toDatabase() or toArray(); it is stored as JSON in data. Read $user->unreadNotifications for the bell and call markAsRead() to set read_at.
solid answer
~40 s`php artisan make:notifications-table` generates a migration for a `notifications` table: a UUID `id`, `type`, a polymorphic `notifiable` pair, a `data` text column and a nullable `read_at`. With `'database'` in `via()`, the channel stores what `toDatabase()` returns — or `toArray()` when there is no `toDatabase()` — as JSON in `data`, and `type` defaults to the notification's class name. `toArray()` is also the fallback payload for the `broadcast` channel, so define `toDatabase()` when the stored and pushed shapes differ. The `Notifiable` trait adds `notifications`, `unreadNotifications` and `readNotifications` relationships, newest first. `$notification->markAsRead()` sets `read_at`; on a collection it saves each row separately, so "mark all as read" is better as `$user->unreadNotifications()->update(['read_at' => now()])`. Adding `'broadcast'` also pushes the notification live on the private `App.Models.User.{id}` channel.
code
php · 20 lines<?php
public function via(object $notifiable): array
{
return ['mail', 'database', 'broadcast'];
}
public function toDatabase(object $notifiable): array
{
return [
'appointment_id' => $this->appointment->id,
'message' => 'Check-up tomorrow at '.$this->appointment->starts_at->format('H:i'),
'url' => route('appointments.show', $this->appointment),
];
}
public function databaseType(object $notifiable): string
{
return 'appointment-reminder';
}go deeper
Recall make:notifications-table, adding 'database' to via(), returning an array from toArray(), and reading unreadNotifications then calling markAsRead().
Explain the table columns, the toDatabase-over-toArray precedence and its interplay with broadcast, and the cost difference between collection markAsRead() and a query update().
Show production care: small render-ready payloads, a stable databaseType(), UUID morph columns where needed, pruning old rows, and a broadcast channel for live badges.
Decide what the bell is for — an audit trail or a transient inbox — and set retention, payload versioning and read-state semantics across web and mobile clients.
## The database channel in one picture An in-app bell needs three things: somewhere to store each notification, a way to list unread ones, and a way to mark them read. Laravel's `database` notification channel provides all three on top of an ordinary table and an Eloquent model, `Illuminate\Notifications\DatabaseNotification`. ## Creating the table The skeleton does not ship this table. Generate it: ```bash php artisan make:notifications-table php artisan migrate ``` | Column | Type | Holds | |---|---|---| | `id` | UUID, primary key | the notification id, shared by all channels of one send | | `type` | string | the notification class name, unless you override it | | `notifiable_type`, `notifiable_id` | morph columns | which model received it | | `data` | text, cast to `array` | the JSON payload from your notification | | `read_at` | nullable timestamp | `null` until read | | `created_at`, `updated_at` | timestamps | ordering and display | If your notifiable models use UUID or ULID primary keys, change `morphs('notifiable')` to `uuidMorphs` or `ulidMorphs` before migrating. ## Choosing the payload With `'database'` in `via()`, the channel builds the stored row like this: - **`data`** — the array from `toDatabase()` if it exists, otherwise from `toArray()`. If neither exists, a `RuntimeException` is thrown. - **`type`** — `databaseType($notifiable)` if defined, otherwise the fully qualified class name. - **`read_at`** — `initialDatabaseReadAtValue()` if defined, otherwise `null`. Why two array methods? `toArray()` is shared: the `broadcast` channel also falls back to it when there is no `toBroadcast()`. When the bell needs different fields from the live push, define `toDatabase()` for storage and let `toArray()` or `toBroadcast()` serve the broadcast. Store what the bell needs to *render*: `appointment_id`, a short message, a URL. Avoid dumping whole models; the JSON is a snapshot and goes stale if the appointment changes. ## Reading and marking 1. **Badge count:** `$user->unreadNotifications()->count()` runs one `COUNT` query. 2. **Dropdown list:** `$user->unreadNotifications()->limit(10)->get()` — the relationships are ordered newest first. 3. **Open one:** `$notification->markAsRead()` sets `read_at` if it is still `null`; `markAsUnread()` reverses it. 4. **Mark all read:** `$user->unreadNotifications()->update(['read_at' => now()])` is a single `UPDATE`. By contrast, `$user->unreadNotifications->markAsRead()` on the loaded collection saves each notification one by one. 5. **Clear:** `$user->notifications()->delete()`. Each stored notification is an Eloquent model, so `$notification->data['appointment_id']` is already an array — the `data` cast handles decoding. ## Making it live The database channel alone requires a page refresh or polling. Adding `'broadcast'` to `via()` pushes the notification in real time: - the payload comes from `toBroadcast()` (a `BroadcastMessage`) or falls back to `toArray()`; - Laravel adds the notification `id` and `type` to the payload, so the client can mark exactly that row read later; - by default it goes to the private channel `App.Models.User.{id}` — the notifiable's class with dots, plus its key — which a model can change with `receivesBroadcastNotificationsOn()`. Listening on the client belongs to the broadcasting setup, not to the notification class. ## Serving the bell to a frontend A JavaScript or mobile client cannot query the table directly; expose a small controller: - **List:** return `id`, `type`, `data`, `read_at` and `created_at` for the current user's latest notifications, paginated. - **Mark read:** accept an id and look it up **through the user's relationship** — `$request->user()->notifications()->findOrFail($id)` — before calling `markAsRead()`. Looking it up with `DatabaseNotification::find($id)` would let one patient mark, or read, another patient's notifications by guessing ids. - **Mark all:** one `update()` on `unreadNotifications()`. - **Badge:** a `count()` query, cheap enough to include in every page's shared data. Because the notification `id` is a UUID shared by every channel of one send, the id that arrives in a broadcast payload is the same id stored in the table, so the client can mark exactly the row it just displayed. ## Pitfalls - **Renaming a notification class** orphans old rows whose `type` holds the old name; `databaseType()` returning a stable alias such as `appointment-reminder` avoids that. - **On-demand notifications** cannot use the database channel, because there is no model to attach the row to. - **Unbounded growth**: the table keeps every notification; prune old read rows on a schedule. ## Summary - `make:notifications-table`, `'database'` in `via()`, an array from `toDatabase()` or `toArray()`. - `unreadNotifications` for the bell; `markAsRead()` per row, a query `update()` for all. - Add `'broadcast'` for live updates on `App.Models.User.{id}`.
- Why might you define databaseType() on a notification?By default the `type` column stores the fully qualified class name. If you later rename or move the class, old rows still point at the old name and filters or renderers keyed on `type` break. `databaseType()` returning a stable alias such as `appointment-reminder` decouples stored data from the class name.
- Your users table uses UUID primary keys. What must change in the notifications migration?Replace `$table->morphs('notifiable')`, which creates an integer `notifiable_id`, with `$table->uuidMorphs('notifiable')` (or `ulidMorphs` for ULIDs) before running it, so the morph id column can hold the user's key.
saying these in an interview costs you the question
- The database channel ignores toArray() when toDatabase() is missing.
- The data column stores a serialized copy of the whole notification object.
- Calling markAsRead() on a loaded collection runs a single UPDATE query.
- make:notification also creates the notifications table.
- read_at is set as soon as the notification is written to the table.