Notifications Guide
Guren provides a unified API for sending notifications across multiple channels like email, database, Slack, and more. Notifications are class-based, making them reusable and easy to test.
Notifications Guide
Guren provides a unified API for sending notifications across multiple channels like email, database, Slack, and more. Notifications are class-based, making them reusable and easy to test.
Core Concepts
- Notification – A class representing a notification with methods for each delivery channel.
- NotificationManager – Central hub for registering channels and sending notifications.
- Notifiable – Interface for entities that can receive notifications (users, teams, etc.).
- Channel – Delivery mechanism (mail, database, Slack, etc.).
Creating Notifications
Basic Notification
import { Notification } from '@guren/core'
import type { Notifiable, NotificationMailMessage } from '@guren/core'
export class OrderShipped extends Notification {
constructor(
private readonly order: Order,
private readonly trackingNumber: string
) {
super()
}
// Define delivery channels
via(notifiable: Notifiable): string[] {
return ['mail', 'database']
}
// Email content
toMail(notifiable: Notifiable): NotificationMailMessage {
return {
subject: `Order #${this.order.id} has shipped!`,
html: `
<h1>Your order is on its way!</h1>
<p>Tracking number: ${this.trackingNumber}</p>
<a href="/orders/${this.order.id}">View Order</a>
`,
}
}
// Database record
toDatabase(notifiable: Notifiable): Record<string, unknown> {
return {
orderId: this.order.id,
trackingNumber: this.trackingNumber,
message: 'Your order has been shipped',
}
}
}
With CLI
bunx guren make:notification OrderShipped
This creates app/Notifications/OrderShippedNotification.ts, a Notification
subclass with via(), toMail(), toDatabase() and toArray() to fill in. It
also pins type to the generated class name:
override get type(): string {
return this.constructor === OrderShippedNotification ? 'OrderShippedNotification' : this.constructor.name
}
type is the key a queue worker rebuilds the notification from and the type
the database channel stores. Without the override it is the class name, which a
bundler can rename. Change the string before the first notification is queued
or stored if you want a different one.
The constructor check limits the pin to the generated class, the way a job's
jobName only counts on the class that declares it. A getter is inherited, so
without the check a subclass would report the same type and replace this
class in the registry. A subclass resolves by its own class name until it
overrides type itself.
Sending Notifications
Setup
import {
NotificationManager,
MailChannel,
DatabaseChannel,
} from '@guren/core'
const notifications = new NotificationManager()
// Register channels
notifications
.registerChannel('mail', new MailChannel(mailManager))
.registerChannel('database', new DatabaseChannel())
Container Integration
The notification subsystem is registered as a singleton via a ServiceProvider. You can resolve it from the container:
// Access via app.container or this.container in providers
const notifications = container.make('notifications') // NotificationManager
await notifications.send(user, new OrderShipped(order, 'ABC123'))
Sending to a User
// Anything with routeNotificationFor() is a Notifiable
const user: Notifiable = {
routeNotificationFor(channel: string): string | null {
if (channel === 'mail') return 'user@example.com'
return null
},
}
// Send notification
await notifications.send(user, new OrderShipped(order, 'ABC123'))
Sending to Multiple Users
await notifications.sendToMany(users, new OrderShipped(order, 'ABC123'))
Immediate Sending (Skip Queue)
// Send immediately even if notification is configured to queue
await notifications.sendNow(user, new OrderShipped(order, 'ABC123'))
Notification Channels
Mail Channel
import { MailChannel } from '@guren/core'
const mailChannel = new MailChannel(mailManager, {
from: 'notifications@example.com',
})
// In notification class
toMail(notifiable: Notifiable): NotificationMailMessage {
return {
subject: 'Welcome!',
html: '<h1>Welcome to our platform!</h1>',
text: 'Welcome to our platform!',
from: 'hello@example.com', // Override default
replyTo: 'support@example.com',
cc: ['admin@example.com'],
attachments: [{
filename: 'welcome.pdf',
path: './storage/welcome.pdf',
}],
}
}
Database Channel
Store notifications in the database:
import { DatabaseChannel } from '@guren/core'
const databaseChannel = new DatabaseChannel({
store: async (notifiable, notification) => {
await Notification.create({
id: notification.id,
type: notification.type,
notifiableId: notifiable.id,
notifiableType: 'User',
data: notification.data,
readAt: null,
createdAt: notification.createdAt,
})
},
})
// In notification class
toDatabase(notifiable: Notifiable): Record<string, unknown> {
return {
title: 'New Comment',
message: 'Someone commented on your post',
postId: this.post.id,
commentId: this.comment.id,
}
}
Slack Channel
import { SlackChannel } from '@guren/core'
const slackChannel = new SlackChannel({
webhookUrl: process.env.SLACK_WEBHOOK_URL,
channel: '#notifications', // Default channel
username: 'Notification Bot',
})
// In notification class
toSlack(notifiable: Notifiable): SlackMessage {
return {
text: `Order #${this.order.id} has been shipped!`,
blocks: [
{
type: 'section',
text: {
type: 'mrkdwn',
text: `*Order Shipped* :package:\nTracking: ${this.trackingNumber}`,
},
},
{
type: 'actions',
elements: [
{
type: 'button',
text: { type: 'plain_text', text: 'View Order' },
url: `https://example.com/orders/${this.order.id}`,
},
],
},
],
}
}
Memory Channel (Testing)
import { MemoryChannel } from '@guren/core'
const memoryChannel = new MemoryChannel()
// Later, check sent notifications
const sent = memoryChannel.sent
Notifiable Interface
Entities receiving notifications must implement Notifiable:
import type { Notifiable } from '@guren/core'
class User implements Notifiable {
id: number
email: string
slackId?: string
phone?: string
routeNotificationFor(channel: string): string | null {
switch (channel) {
case 'mail':
return this.email
case 'slack':
return this.slackId ?? null
case 'sms':
return this.phone ?? null
default:
return null
}
}
}
Pinning the Notifiable Type
The database channel persists a notifiableType alongside each record, and it
defaults to the constructor name. That name is lost in two places: a bundler can
mangle it, and a notifiable rebuilt from a queued payload is a plain object, so
its constructor name is Object.
Declare notifiableType to pin it — the mirror of Notification.type on the
notification side:
class User implements Notifiable {
notifiableType = 'User'
routeNotificationFor(channel: string): string | null {
// ...
}
}
The declared type is serialized into the queue payload and restored onto the notifiable the worker rebuilds, so it survives the round trip.
Queued Notifications
Configure Queue
export class WelcomeNotification extends Notification {
// Enable queuing
static shouldQueue = true
// Specify queue name (optional)
static queue = 'notifications'
// Add delay in milliseconds (optional)
static delay = 5000 // 5 seconds
via(notifiable: Notifiable): string[] {
return ['mail']
}
toMail(notifiable: Notifiable): NotificationMailMessage {
return {
subject: 'Welcome!',
html: '<h1>Welcome to our app!</h1>',
}
}
}
Queue Setup
import { createQueueManager, MemoryDriver } from '@guren/core'
const queue = createQueueManager({
default: 'memory',
drivers: {
memory: () => new MemoryDriver(),
},
})
queue.driver()
// Notifications with shouldQueue = true will be queued
await notifications.send(user, new WelcomeNotification())
// Process with worker
// bunx guren queue:work --queue=notifications
Workers in a Separate Process
A queued notification is stored as data, so the worker rebuilds the class to
deliver it. When the worker shares the process that sent the notification, this
happens automatically. When you run queue:work as its own process, register
the notification classes it needs from a provider:
import { ServiceProvider, registerNotification } from '@guren/core'
import { WelcomeNotification } from '@/app/Notifications/WelcomeNotification'
export class NotificationServiceProvider extends ServiceProvider {
register(): void {
registerNotification(WelcomeNotification)
}
}
An unregistered notification fails loudly rather than silently delivering nothing.
The registry key is the notification's type, which defaults to the class name.
When a different class registers under a type another class holds, through
registerNotification() or by being queued, it warns once, naming both, and the
worker rebuilds the class registered last. Give one of them a distinct class
name or override its type getter. Registering one class under a second type,
to keep an old type resolving, is silent. A future major will throw instead of
warning.
Two things do not survive the queue, because only a notification's own properties are stored:
- Constructor arguments. Keep anything a channel needs in a property, as
WelcomeNotificationdoes above — the constructor is not re-run. - Values JSON cannot represent, such as
Map,Set, or#privatefields. Prefer plain values.
Routing is unaffected: routeNotificationFor() is called when the notification
is queued and the resulting routes travel with it, so per-recipient addresses
and webhooks arrive intact.
Conditional Notifications
shouldSend Method
class OrderStatusNotification extends Notification {
constructor(private readonly order: Order) {
super()
}
// Only send if user has notifications enabled
async shouldSend(notifiable: Notifiable): Promise<boolean> {
const user = notifiable as User
return user.notificationsEnabled && !user.isDeleted
}
via(notifiable: Notifiable): string[] {
const channels = ['database']
// Add mail only if user opted in
if ((notifiable as User).emailNotifications) {
channels.push('mail')
}
return channels
}
toMail(notifiable: Notifiable): NotificationMailMessage {
return {
subject: `Order #${this.order.id} Update`,
html: `<p>Your order status: ${this.order.status}</p>`,
}
}
toDatabase(notifiable: Notifiable): Record<string, unknown> {
return {
orderId: this.order.id,
status: this.order.status,
}
}
}
Custom Channels
Create custom notification channels:
import type { NotificationChannel, Notifiable } from '@guren/core'
import type { Notification } from '@guren/core'
class SMSChannel implements NotificationChannel {
readonly name = 'sms'
constructor(private readonly twilioClient: TwilioClient) {}
async send(notifiable: Notifiable, notification: Notification): Promise<void> {
const phone = notifiable.routeNotificationFor('sms')
if (!phone) return
// Get SMS content from notification
const message = (notification as any).toSMS?.(notifiable)
if (!message) return
await this.twilioClient.messages.create({
to: phone,
from: process.env.TWILIO_FROM,
body: message.body,
})
}
}
// Register the channel
notifications.registerChannel('sms', new SMSChannel(twilioClient))
// Use in notification
class OrderConfirmation extends Notification {
via(notifiable: Notifiable): string[] {
return ['mail', 'sms']
}
toSMS(notifiable: Notifiable) {
return {
body: `Order #${this.order.id} confirmed! Total: $${this.order.total}`,
}
}
}
Testing
Using container.fake()
Swap the notification manager in tests. Code that resolves notifications from the container, as in Container Integration, then sends through the fake, and the swap is undone when _ goes out of scope:
import { expect, test } from 'bun:test'
import { MemoryChannel, NotificationManager } from '@guren/core'
import type { Notifiable } from '@guren/core'
import app from '@/src/app'
import type { Order } from '@/app/Models/Order'
import { OrderShipped } from '@/app/Notifications/OrderShipped'
test('sends the shipping notification by mail', async () => {
const mail = new MemoryChannel()
using _ = app.container.fake('notifications', new NotificationManager({
channels: { mail, database: new MemoryChannel() },
}))
// Stands in for the code under test, which resolves the manager the same way
const user: Notifiable = { routeNotificationFor: () => 'ada@example.com' }
const order = { id: 7 } as Order
await app.container.make('notifications').send(user, new OrderShipped(order, 'ABC123'))
const sent = mail.sent
expect(sent).toHaveLength(1)
expect(sent[0].notification).toBeInstanceOf(OrderShipped)
})
Manual Testing
import { describe, test, expect, beforeEach } from 'bun:test'
import {
NotificationManager,
MemoryChannel,
Notification,
} from '@guren/core'
import type { Notifiable, NotificationMailMessage } from '@guren/core'
class TestNotification extends Notification {
via() {
return ['memory']
}
toMail(): NotificationMailMessage {
return { subject: 'Test', html: '<p>Test</p>' }
}
}
describe('Notifications', () => {
let notifications: NotificationManager
let memoryChannel: MemoryChannel
beforeEach(() => {
memoryChannel = new MemoryChannel()
notifications = new NotificationManager({
channels: { memory: memoryChannel },
})
})
test('sends notification', async () => {
const user: Notifiable = {
routeNotificationFor: () => 'test@example.com',
}
await notifications.send(user, new TestNotification())
const sent = memoryChannel.sent
expect(sent).toHaveLength(1)
expect(sent[0].notification).toBeInstanceOf(TestNotification)
})
test('respects shouldSend', async () => {
class ConditionalNotification extends Notification {
shouldSend() {
return false
}
via() {
return ['memory']
}
}
const user: Notifiable = {
routeNotificationFor: () => 'test@example.com',
}
await notifications.send(user, new ConditionalNotification())
expect(memoryChannel.sent).toHaveLength(0)
})
test('sends to multiple channels', async () => {
const mailChannel = new MemoryChannel()
const dbChannel = new MemoryChannel()
notifications.registerChannel('mail', mailChannel)
notifications.registerChannel('database', dbChannel)
class MultiChannelNotification extends Notification {
via() {
return ['mail', 'database']
}
toMail() {
return { subject: 'Test', html: '<p>Test</p>' }
}
toDatabase() {
return { message: 'Test' }
}
}
const user: Notifiable = {
routeNotificationFor: () => 'test@example.com',
}
await notifications.send(user, new MultiChannelNotification())
expect(mailChannel.sent).toHaveLength(1)
expect(dbChannel.sent).toHaveLength(1)
})
})
Best Practices
One notification per event: Create separate notification classes for different events.
Keep notifications focused: Each notification should have a clear purpose.
Use queuing for non-critical notifications: Queue email and Slack notifications to avoid blocking.
Implement shouldSend for conditional logic: Use
shouldSend()instead of checking conditions before sending.Test notification content: Write tests to verify notification messages are correct.
Handle channel failures gracefully: Channels should log errors without breaking other channels.
Use typed payloads: Define interfaces for notification data for type safety.
Store database notifications for in-app notifications: Use the database channel for notification centers.