Implementing Notification System: Email, SMS, Push, In-App
Notification system informs users about events through multiple channels. User chooses which notifications and channels to receive. Key tasks: centralized preferences storage, sending queue, deduplication.
Architecture
[Event: OrderShipped]
↓
[NotificationService]
├── Check user preferences
├── Email: to queue → SendGrid/Mailgun
├── SMS: to queue → SMSC/Twilio
├── Push: to queue → Firebase FCM
└── In-App: save to DB → WebSocket push
Database Structure
-- User notification preferences
CREATE TABLE notification_preferences (
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
notification_type VARCHAR(100) NOT NULL, -- 'order.shipped', 'comment.reply', etc.
email_enabled BOOLEAN NOT NULL DEFAULT true,
sms_enabled BOOLEAN NOT NULL DEFAULT false,
push_enabled BOOLEAN NOT NULL DEFAULT true,
inapp_enabled BOOLEAN NOT NULL DEFAULT true,
PRIMARY KEY (user_id, notification_type)
);
-- In-App notifications
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,
title VARCHAR(255),
body TEXT,
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);
-- Push tokens
CREATE TABLE push_tokens (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token TEXT NOT NULL UNIQUE,
platform VARCHAR(20) NOT NULL, -- 'web', 'ios', 'android'
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
Laravel: Basic Implementation
class NotificationService
{
public function notify(User $user, string $type, array $payload): void
{
$prefs = NotificationPreference::where('user_id', $user->id)
->where('notification_type', $type)
->first();
// Default settings
$defaults = [
'email_enabled' => true,
'sms_enabled' => false,
'push_enabled' => true,
'inapp_enabled' => true,
];
$channels = array_merge($defaults, $prefs?->toArray() ?? []);
// In-App: synchronously (save + WebSocket)
if ($channels['inapp_enabled']) {
$notification = Notification::create([
'user_id' => $user->id,
'type' => $type,
'title' => $payload['title'] ?? null,
'body' => $payload['body'] ?? null,
'data' => $payload['data'] ?? [],
]);
broadcast(new NewNotificationEvent($user, $notification))->toOthers();
}
// Email: to queue
if ($channels['email_enabled'] && isset($payload['email'])) {
SendEmailNotificationJob::dispatch($user, $type, $payload['email'])->onQueue('notifications');
}
// SMS: to queue
if ($channels['sms_enabled'] && $user->phone && isset($payload['sms'])) {
SendSmsNotificationJob::dispatch($user, $payload['sms'])->onQueue('notifications');
}
// Push: to queue
if ($channels['push_enabled'] && isset($payload['push'])) {
SendPushNotificationJob::dispatch($user, $payload['push'])->onQueue('notifications');
}
}
}
// Usage example
app(NotificationService::class)->notify($user, 'order.shipped', [
'title' => 'Your order has been shipped',
'body' => "Order #{$order->number} handed to delivery service",
'data' => ['order_id' => $order->id, 'url' => route('orders.show', $order)],
'email' => ['order' => $order], // data for email template
'sms' => "Order #{$order->number} shipped. Track: {$order->tracking_number}",
'push' => ['title' => 'Order shipped', 'body' => "Order #{$order->number}"],
]);
Email: SendGrid
class SendEmailNotificationJob implements ShouldQueue
{
public int $tries = 3;
public int $backoff = 60;
public function __construct(
private User $user,
private string $type,
private array $emailData,
) {}
public function handle(): void
{
$mailable = $this->resolveMailable($this->type, $this->emailData);
Mail::to($this->user->email)->send($mailable);
}
private function resolveMailable(string $type, array $data): Mailable
{
return match ($type) {
'order.shipped' => new OrderShippedMail($data['order']),
'comment.reply' => new CommentReplyMail($data['comment']),
'password.reset' => new PasswordResetMail($data['token']),
default => new GenericNotificationMail($type, $data),
};
}
}
SMS: SMSC.ru (Russia)
class SendSmsNotificationJob implements ShouldQueue
{
public function __construct(private User $user, private string $text) {}
public function handle(): void
{
Http::get('https://smsc.ru/sys/send.php', [
'login' => config('services.smsc.login'),
'psw' => config('services.smsc.password'),
'phones' => $this->user->phone,
'mes' => $this->text,
'charset' => 'utf-8',
'fmt' => 3, // JSON
])->throw();
}
}
Push: Firebase Cloud Messaging
class SendPushNotificationJob implements ShouldQueue
{
public function __construct(private User $user, private array $pushData) {}
public function handle(): void
{
$tokens = PushToken::where('user_id', $this->user->id)->pluck('token')->toArray();
if (empty($tokens)) return;
$response = Http::withToken(config('services.firebase.server_key'))
->post('https://fcm.googleapis.com/fcm/send', [
'registration_ids' => $tokens,
'notification' => [
'title' => $this->pushData['title'],
'body' => $this->pushData['body'],
'icon' => '/icon-192.png',
'click_action' => $this->pushData['url'] ?? '/',
],
'data' => $this->pushData['data'] ?? [],
]);
// Remove invalid tokens
$results = $response->json('results', []);
foreach ($results as $index => $result) {
if (isset($result['error']) && in_array($result['error'], ['InvalidRegistration', 'NotRegistered'])) {
PushToken::where('token', $tokens[$index])->delete();
}
}
}
}
Web Push: Service Worker Subscription
// Subscribe to Web Push
async function subscribeToPush(): Promise<void> {
const registration = await navigator.serviceWorker.ready;
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(import.meta.env.VITE_VAPID_PUBLIC_KEY),
});
await api.post('/api/push-tokens', {
token: JSON.stringify(subscription),
platform: 'web',
});
}
React: Notification Center
function NotificationCenter() {
const { data, refetch } = useQuery({ queryKey: ['notifications'], queryFn: fetchNotifications });
const unread = data?.filter(n => !n.read_at).length ?? 0;
// Real-time updates via WebSocket
useEffect(() => {
const echo = window.Echo.private(`notifications.${currentUser.id}`)
.listen('NewNotificationEvent', () => refetch());
return () => echo.stopListening('NewNotificationEvent');
}, []);
return (
<div className="notification-center">
<button className="bell" aria-label={`Notifications: ${unread} unread`}>
<BellIcon />
{unread > 0 && <span className="badge">{unread > 99 ? '99+' : unread}</span>}
</button>
<ul className="notification-list">
{data?.map(notification => (
<li key={notification.id} className={notification.read_at ? '' : 'unread'}>
<span>{notification.title}</span>
<time>{timeAgo(notification.created_at)}</time>
</li>
))}
</ul>
</div>
);
}
Implementation Timeline
| Task | Timeline |
|---|---|
| In-App notifications + WebSocket | 2–3 days |
| Email channel with templates | +1–2 days |
| SMS via SMSC/Twilio | +1 day |
| Firebase Push notifications | +1–2 days |
| User notification preferences | +1–2 days |
| Complete system all channels + UI | 7–10 days |







