A notification center has a simple but dangerous temptation: if the user opened the notification panel, then everything inside it must have been read.
That is the wrong model for analytics. A bell dropdown may contain ten notifications while the user only glances at the top row and closes it. If all records receive read_at at that moment, the read rate looks good but becomes false.
In our Notification Center, we separated several moments: a notification can be created, shown, dismissed, explicitly read, acknowledged, or clicked. These are different events and need different semantics.
Opening the Dropdown Is Not Reading
Opening the bell dropdown only proves that the user opened the container that lists notifications. It does not prove that a specific notification was fully displayed or read.
Dropdown open should therefore not mass-update read_at and should not automatically change analytics for every listed item.
public function openDropdown(): void{ // Load notifications for display. // Do NOT: // Notification::query() // ->whereNull('read_at') // ->update(['read_at' => now()]);}Shown Means the UI Actually Displayed the Notification
For modal notifications, we record shown_at only when the modal is actually mounted and displayed to the user.
This is useful for UI and badge semantics: the notification is no longer new or "not yet viewed". But shown_at is still not proof of human reading.
if ($notification->shown_at === null) { $notification->forceFill([ 'shown_at' => now(), ])->save();}Dismissed Is Not Read Either
A user can close a modal with Escape or the backdrop without reading the content. Dismissal therefore has its own timestamp, dismissed_at.
Closing must not automatically become read_at. Otherwise analytics once again conflates "removed from the screen" with "read".
$notification->forceFill([ 'dismissed_at' => $notification->dismissed_at ?? now(),])->save(); // read_at stays unchanged.Read Needs a Defined User Action
read_at should mean one concrete, documented user action. The strongest option is an explicit Mark as read, "I have read this", or another agreed action that actually represents acknowledgement of the content.
The key rule is that the timestamp should not appear because of an incidental UI side effect such as opening the dropdown.
public function markAsRead( Notification $notification,): void { if ($notification->read_at !== null) { return; } $notification->forceFill([ 'read_at' => now(), ])->save();}Acknowledged Is Stronger Than Viewed
For modal notifications, we also keep a separate state: acknowledged_at. For example, the user presses an "Understood" button.
In that model, acknowledgement may also set read_at because the action explicitly confirms the notification. But shown_at and acknowledged_at remain separate timestamps.
$now = now(); $notification->forceFill([ 'read_at' => $notification->read_at ?? $now, 'acknowledged_at' => $notification->acknowledged_at ?? $now,])->save();CTA Click Has Its Own Semantics
A CTA click means the user performed a specific action from the notification. It should be recorded through clicked_at.
In our model, a CTA can be sufficient to set read_at, but it does not automatically mean acknowledgement. The notification can remain unconfirmed until a separate confirmation action.
A click also does not mean completion. Navigating to a page and completing the downstream action are different events.
The Badge Should Count New, Not Unread
If the badge only uses read_at IS NULL, it collapses several different states.
In our UX model, the bell badge counts notifications that have not actually been displayed as a modal yet, meaning there is no shown_at. After the first display, the badge decreases even if the notification is still not acknowledged.
$unviewedCount = Notification::query() ->whereNull('shown_at') ->count();The Dropdown Should Show Unconfirmed, Not Unread
After the modal is shown, a notification is no longer new, but the user may still not have confirmed it. It therefore remains in the active dropdown until acknowledged_at.
This is a different query boundary from the badge. The badge answers "what has not been shown yet?" while the dropdown answers "what has not been confirmed yet?"
$activeDropdown = Notification::query() ->whereNull('acknowledged_at') ->latest() ->get();Analytics Should Keep Every State Separate
For useful notification analytics, I do not collapse everything into one read/unread column. At minimum, separate timestamps are useful:
shown_at- the UI actually displayed the notification;dismissed_at- the user closed it without confirmation;read_at- the defined reading action occurred;acknowledged_at- the user explicitly confirmed the notification;clicked_at- the user clicked the CTA.
These timestamps support different funnels without inventing claims about user behavior.
Read Rate Needs a Clear Denominator
Even with precise read_at, the denominator must be explicit. Read rate out of targeted recipients, delivered database notifications, or shown notifications are different metrics.
If a channel cannot reliably establish reading, it is better to keep an unknown or read-unverifiable state than automatically classify the record as unread or read.
An Email Open Does Not Prove Human Reading
The same rule applies to email. A provider delivery acknowledgement means delivery to the channel, while an open pixel may fire automatically or through an image proxy.
Email delivery, email open, and explicit in-app read should not be combined into one "read" metric.
Read Events Must Be Idempotent
A realtime reconnect, repeated click, or duplicate API request should not change the first read moment. Once read_at exists, another action should not create a new read event or move the timestamp forward.
$notification->forceFill([ 'read_at' => $notification->read_at ?? now(),])->save();What to Test
- opening the bell dropdown does not set
read_at; - first modal mount sets
shown_atonce; - Escape/backdrop sets
dismissed_atbut notread_at; - Mark as read sets
read_atonce; - "Understood" sets
acknowledged_atand, by contract,read_at; - CTA sets
clicked_atbut not automaticacknowledged_at; - refresh/reconnect does not return a viewed notification to the badge;
- duplicate read/click events do not corrupt timestamps or counters;
- legacy records are not silently rewritten as explicit acknowledgements.
Analytics should record what the system actually observed, not what we would like to assume about user behavior.
Summary
Opening the notification panel is not the read moment. Showing a modal is not reading either. Dismissal is not reading, and a CTA click does not necessarily mean acknowledgement or completion.
The more precisely we separate shown_at, dismissed_at, read_at, acknowledged_at, and clicked_at, the more honest notification analytics becomes and the easier it is to build correct badge, dropdown, and engagement metrics.



