Implementing Notification Center
Notification center — component showing user's notification history with read marking. Updates real-time via WebSocket without page reload.
Database Structure
CREATE TABLE notifications (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
type VARCHAR(100) NOT NULL,
icon VARCHAR(50),
title VARCHAR(255),
body TEXT,
url VARCHAR(500),
data JSONB NOT NULL DEFAULT '{}',
read_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX ON notifications(user_id, read_at, created_at DESC);
Laravel: API Endpoints
class NotificationController extends Controller
{
public function index(Request $request): JsonResponse
{
$notifications = auth()->user()->notifications()
->latest()
->limit(50)
->get();
return response()->json([
'notifications' => NotificationResource::collection($notifications),
'unread_count' => $notifications->whereNull('read_at')->count(),
]);
}
public function markRead(Request $request): JsonResponse
{
$query = auth()->user()->notifications()->whereNull('read_at');
if ($request->id) {
$query->where('id', $request->id);
}
$query->update(['read_at' => now()]);
return response()->json(['success' => true]);
}
public function send(User $user, array $data): void
{
$notification = $user->notifications()->create($data);
broadcast(new NotificationCreatedEvent($user->id, $notification))->toOthers();
}
}
Laravel Echo + WebSocket
import Echo from 'laravel-echo';
import Pusher from 'pusher-js';
window.Pusher = Pusher;
const echo = new Echo({
broadcaster: 'pusher',
key: import.meta.env.VITE_PUSHER_KEY,
cluster: import.meta.env.VITE_PUSHER_CLUSTER,
forceTLS: true,
});
export function useNotifications(userId: number) {
const [notifications, setNotifications] = useState<Notification[]>([]);
const [unreadCount, setUnreadCount] = useState(0);
useEffect(() => {
api.get('/api/notifications').then(({ data }) => {
setNotifications(data.notifications);
setUnreadCount(data.unread_count);
});
const channel = echo.private(`notifications.${userId}`)
.listen('.NotificationCreated', (event: { notification: Notification }) => {
setNotifications(prev => [event.notification, ...prev].slice(0, 50));
setUnreadCount(c => c + 1);
if (Notification.permission === 'granted') {
new Notification(event.notification.title ?? 'New notification', {
body: event.notification.body ?? undefined,
icon: '/icon-192.png',
});
}
});
return () => channel.stopListening('.NotificationCreated');
}, [userId]);
const markAllRead = async () => {
await api.post('/api/notifications/mark-read');
setNotifications(prev => prev.map(n => ({ ...n, read_at: new Date().toISOString() })));
setUnreadCount(0);
};
return { notifications, unreadCount, markAllRead };
}
React: UI Component
function NotificationBell({ userId }: { userId: number }) {
const { notifications, unreadCount, markAllRead } = useNotifications(userId);
const [isOpen, setIsOpen] = useState(false);
return (
<div className="notification-bell">
<button
onClick={() => setIsOpen(!isOpen)}
aria-label={`${unreadCount} unread notifications`}
aria-expanded={isOpen}
aria-haspopup="true"
>
🔔
{unreadCount > 0 && (
<span className="badge" aria-hidden>{unreadCount > 99 ? '99+' : unreadCount}</span>
)}
</button>
{isOpen && (
<div className="notification-panel" role="dialog" aria-label="Notifications">
<header>
<h2>Notifications</h2>
{unreadCount > 0 && (
<button onClick={markAllRead}>Mark all as read</button>
)}
</header>
<ul>
{notifications.length === 0 && <li className="empty">No notifications</li>}
{notifications.map(notification => (
<li key={notification.id} className={notification.read_at ? 'read' : 'unread'}>
{notification.url ? (
<a href={notification.url}>{notification.title}</a>
) : (
<span>{notification.title}</span>
)}
<time dateTime={notification.created_at}>{timeAgo(notification.created_at)}</time>
{notification.body && <p>{notification.body}</p>}
</li>
))}
</ul>
</div>
)}
</div>
);
}
Timeline
Notification center with WebSocket (Laravel Echo + Pusher), API and React UI: 2–3 days. Self-hosted (Soketi or Laravel Reverb instead of Pusher): +1 day.







