Skip to content

Notifications

Introduction

Goravel's notification system lets you inform users about what's happening in your application — an order shipped, a payment received, a new message waiting. Instead of wiring each of these to a specific delivery mechanism, you write one notification class per message and let Goravel route it through the channels you pick: mail, database, or a custom channel of your own.

A notification describes the message itself. The notifiable (usually one of your models) tells Goravel where to deliver it. facades.Notification() is the entry point for sending.

Installation

The notification facade is not installed by default. Install it with the package:install command:

shell
go run . artisan package:install Notification

Database Table

Before using the database channel, you need a notifications table to store the notifications. Generate its migration with the notifications:table command:

shell
go run . artisan notifications:table

This creates a <timestamp>_create_notifications_table.go migration in database/migrations and auto-registers it in bootstrap/migrations.go. Then run the migration:

shell
go run . artisan migrate

Generating Notifications

By default, all notifications are stored in the app/notifications directory. If that directory doesn't exist, it will be created when you run the make:notification Artisan command:

shell
go run . artisan make:notification OrderShipped
go run . artisan make:notification user/OrderShipped

The command scaffolds a mail-channel notification with a Via method and a ToMail method:

go
package notifications

import (
  "github.com/goravel/framework/contracts/notification"
  "github.com/goravel/framework/notification/mail"
)

type OrderShipped struct {
  OrderID string
}

func NewOrderShipped(orderID string) *OrderShipped {
  return &OrderShipped{OrderID: orderID}
}

func (r *OrderShipped) Via(notifiable notification.Notifiable) []string {
  return []string{notification.ChannelMail}
}

func (r *OrderShipped) ToMail(notifiable notification.Notifiable) notification.MailMessage {
  return mail.NewMessage().
    Subject("Order " + r.OrderID + " has shipped").
    Html("<p>Order " + r.OrderID + " has shipped.</p>").
    Build()
}

To scaffold a database-channel notification instead, pass the --database flag:

shell
go run . artisan make:notification OrderProcessed --database

Notification Structure

A notification is a struct that carries the data needed to build the message. The only required method is Via, which returns the list of channels the notification should be delivered through:

go
package notifications

import (
  "github.com/goravel/framework/contracts/notification"
)

func (r *OrderShipped) Via(notifiable notification.Notifiable) []string {
  return []string{notification.ChannelMail, notification.ChannelDatabase}
}

Depending on the channels returned, the notification must implement the corresponding payload methods:

ChannelContractMethod
mailMailableNotificationToMail(notifiable notification.Notifiable) notification.MailMessage
databaseDatabaseNotificationToDatabase(notifiable notification.Notifiable) map[string]any
customDefined by your custom channel implementation

Optional Contracts

Implement these optional contracts to customize how notifications are sent:

ContractMethodPurpose
NotificationWithIDID() stringA custom ID for the notification (used by the database channel); defaults to a UUID
NotificationWithShouldSendShouldSend(notifiable notification.Notifiable, channel string) boolSkip delivery for a given channel when returning false
NotificationWithAfterSendingAfterSending(notifiable notification.Notifiable, channel string) errorRun a hook after a successful channel delivery
NotificationWithDatabaseConnectionDatabaseConnection() stringThe database connection the notification row should be stored on; "" for the default
ShouldQueueOnQueue() string, OnConnection() stringQueue the notification instead of sending it synchronously
NotificationWithTriesTries(channel string) intThe maximum number of attempts for the given channel; 0 (or not implementing) means no retry policy is declared and the queue worker's Tries config applies
NotificationWithBackoffBackoff(channel string) []time.DurationThe delay before each retry attempt on channel, in order; the last value repeats. Applies to every retry, whether capped by the notification's own Tries or the queue worker's Tries
go
// A custom ID used as the notifications table primary key.
func (r *Welcome) ID() string {
  return "welcome-notification"
}

// Skip delivery for the given channel when returning false.
func (r *OrderShipped) ShouldSend(notifiable notification.Notifiable, channel string) bool {
  return r.OrderID != ""
}

// Run a hook after a successful channel delivery.
func (r *OrderShipped) AfterSending(notifiable notification.Notifiable, channel string) error {
  return nil
}

// Store the notification on a non-default connection.
func (r *OrderProcessed) DatabaseConnection() string {
  return "reporting"
}

// Cap queued delivery attempts for the channel at 3.
func (r *OrderProcessed) Tries(channel string) int {
  return 3
}

// Wait 10s then 30s between attempts; the last value repeats.
func (r *OrderProcessed) Backoff(channel string) []time.Duration {
  return []time.Duration{10 * time.Second, 30 * time.Second}
}

Sending Notifications

Using The Notifiable Contract

Every notification is addressed to a notifiable — typically one of your models. To make a model notifiable, implement the Notifiable contract. For the built-in channels, implement the typed routing contracts — MailRoutable with RouteNotificationForMail and DatabaseRoutable with RouteNotificationForDatabase — which take precedence over the generic RouteNotificationFor method. RouteNotificationFor remains the fallback and is still how custom channels resolve their addresses:

go
package models

import (
  "strconv"

  "github.com/goravel/framework/contracts/notification"
)

type User struct {
  ID   uint
  Mail string
  Name string
}

// RouteNotificationForMail implements contracts/notification.MailRoutable:
// the type-safe mail delivery route, preferred over RouteNotificationFor.
func (r *User) RouteNotificationForMail(notification notification.Notification) map[string]string {
  return map[string]string{r.Mail: r.Name}
}

// RouteNotificationForDatabase implements contracts/notification.DatabaseRoutable:
// the type-safe database delivery route, preferred over RouteNotificationFor.
func (r *User) RouteNotificationForDatabase() string {
  return strconv.FormatUint(uint64(r.ID), 10)
}

// RouteNotificationFor resolves the delivery address per channel. The built-in
// mail and database channels resolve via the typed interfaces above, so no
// route is left to match on the channel name here.
func (r *User) RouteNotificationFor(channel string) any {
  return nil
}

The address type is channel-specific:

ChannelAccepted types
mailstring (single address), []string (multiple addresses), map[string]string (address → name)
databasestring (model primary key; numeric IDs are auto-converted)
customAny type your custom channel understands

RouteNotificationForMail returns the map[string]string address → name mapping directly, and RouteNotificationForDatabase returns the primary key as a string, so the typed routes never leave the address shape to chance.

Then send the notification with facades.Notification().Send():

go
package controllers

import (
  "github.com/goravel/framework/contracts/http"

  "goravel/app/facades"
  "goravel/app/models"
  "goravel/app/notifications"
)

func (c *OrderController) Ship(ctx http.Context) http.Response {
  var user models.User

  if err := facades.Orm().Query().Find(&user, 1); err != nil {
    return ctx.Response().String(http.StatusInternalServerError, err.Error())
  }

  if err := facades.Notification().Send(user, notifications.NewOrderShipped("12345")); err != nil {
    return ctx.Response().String(http.StatusInternalServerError, err.Error())
  }

  return ctx.Response().Success().Json(http.Json{"message": "shipped"})
}

Using The Route Method

When the recipient isn't one of your models — a bare email address or an ID is all you have — skip the notifiable and pass the delivery address inline with Route. It returns an on-demand notifiable you can chain additional routes onto, then send with Notify:

go
package controllers

import (
  "github.com/goravel/framework/contracts/notification"

  "goravel/app/facades"
  "goravel/app/notifications"
)

func SendWelcome() error {
  err := facades.Notification().Route(notification.ChannelMail, "example@example.com").Notify(notifications.NewWelcome("Bowen"))
  if err != nil {
    return err
  }

  // Chain routes for multiple channels.
  return facades.Notification().
    Route(notification.ChannelDatabase, "123").
    Route(notification.ChannelMail, "example@example.com").
    Notify(notifications.NewWelcome("Bowen"))
}

Send vs. SendNow

Send delivers through the channels returned by Via. If the notification implements ShouldQueue, the delivery is dispatched as a queued job instead of running inline; otherwise it runs synchronously in the current request. SendNow always delivers synchronously, bypassing the queue even for ShouldQueue notifications:

go
package controllers

import (
  "github.com/goravel/framework/contracts/notification"

  "goravel/app/facades"
  "goravel/app/models"
  "goravel/app/notifications"
)

func Deliver() error {
  var user models.User

  // Runs inline (or via the queue if OrderProcessed implements ShouldQueue).
  if err := facades.Notification().Send(user, notifications.NewOrderProcessed("12345")); err != nil {
    return err
  }

  // Always delivers synchronously, never queued.
  if err := facades.Notification().SendNow(user, notifications.NewOrderProcessed("12345")); err != nil {
    return err
  }

  // On-demand equivalents.
  return facades.Notification().Route(notification.ChannelDatabase, "123").NotifyNow(notifications.NewWelcome("Bowen"))
}

Mail Notifications

When a notification's Via returns mail, implement MailableNotification and provide a ToMail method that builds the outgoing email as a notification.MailMessage. Use the fluent mail.NewMessage() builder from the github.com/goravel/framework/notification/mail package:

go
package notifications

import (
  "github.com/goravel/framework/contracts/notification"
  "github.com/goravel/framework/notification/mail"
)

func (r *OrderShipped) ToMail(notifiable notification.Notifiable) notification.MailMessage {
  return mail.NewMessage().
    Subject("Order " + r.OrderID + " has shipped").
    Html("<p>Order " + r.OrderID + " has shipped.</p>").
    Text("Order " + r.OrderID + " has shipped.").
    Build()
}

The builder supports the following methods:

MethodDescription
Subject(subject string)The email subject; defaults to the notification type name
To(addresses ...string)Override the recipient(s); when unset, recipients resolve from MailRoutable.RouteNotificationForMail (falling back to RouteNotificationFor), preserving the address → name mapping
From(address string)Override the sender; empty uses the global mail.from config
ReplyTo(address string)Sets the Reply-To header
Html(html string)Sets the HTML body
Text(text string)Sets the plain-text body
HtmlView(view string, with map[string]any)Renders the HTML body from a template
TextView(view string, with map[string]any)Renders the plain-text body from a template
Attach(paths ...string)Attaches files by absolute path
Header(key, value string)Adds an arbitrary email header
Build()Returns the finished MailMessage

Routing Mail Notifications

Prefer implementing the MailRoutable contract on the notifiable. Its RouteNotificationForMail returns a map[string]string of address → name pairs and takes precedence over RouteNotificationFor:

go
package models

import (
  "github.com/goravel/framework/contracts/notification"

  "goravel/app/notifications"
)

func (r *User) RouteNotificationForMail(notification notification.Notification) map[string]string {
  if _, ok := notification.(*notifications.OrderShipped); ok {
    return map[string]string{"shipments@example.com": "Shipments"}
  }

  return map[string]string{r.Mail: r.Name}
}

An empty result from RouteNotificationForMail is not an error by itself: the mail channel falls back to RouteNotificationFor(notification.ChannelMail), which accepts a single string address, multiple []string addresses, or a map[string]string of address → name pairs:

go
package models

import (
  "github.com/goravel/framework/contracts/notification"
)

func (r *User) RouteNotificationFor(channel string) any {
  if channel == notification.ChannelMail {
    return map[string]string{"example@example.com": "Bowen"}
  }

  return nil
}

If both return empty, sending fails with a NotificationMailEmptyRoute error.

Database Notifications

For the database channel, implement DatabaseNotification and return the data to persist from ToDatabase. Goravel JSON-encodes the returned map and stores it in the data column of the notifications table:

go
func (r *OrderProcessed) ToDatabase(notifiable notification.Notifiable) map[string]any {
  return map[string]any{
    "order_id": r.OrderID,
  }
}

The notifications table has the following columns:

ColumnTypeDescription
idstring(36)Primary key; a UUID by default, or ID() when implemented
typestringThe notification's type name
notifiable_typestringThe notifiable's type name
notifiable_idstringThe delivery address returned by RouteNotificationForDatabase (falling back to RouteNotificationFor)
datatextThe JSON-encoded data returned by ToDatabase
read_attimestampNullable; marks the notification as read
created_at / updated_attimestampTimestamps

Routing Database Notifications

Prefer implementing the DatabaseRoutable contract on the notifiable. Its RouteNotificationForDatabase takes precedence over RouteNotificationFor and returns the notifiable's primary key as a string:

go
package models

import (
  "strconv"

  "github.com/goravel/framework/contracts/notification"
)

// RouteNotificationForDatabase implements contracts/notification.DatabaseRoutable:
// the type-safe database delivery route, preferred over RouteNotificationFor.
func (r *User) RouteNotificationForDatabase() string {
  return strconv.FormatUint(uint64(r.ID), 10)
}

An empty result from RouteNotificationForDatabase is not an error by itself: the database channel falls back to RouteNotificationFor(notification.ChannelDatabase), which accepts the primary key as a string (numeric IDs are auto-converted):

go
package models

import (
  "github.com/goravel/framework/contracts/notification"
)

func (r *User) RouteNotificationFor(channel string) any {
  if channel == notification.ChannelDatabase {
    return r.ID
  }

  return nil
}

If both return empty, sending fails with a NotificationDatabaseEmptyRoute error.

Retrieving Notifications

Query the notifications table directly with the Query Builder to retrieve a user's notifications:

go
package models

import (
  "time"
)

type Notification struct {
  ID        string     `gorm:"primaryKey;column:id"`
  Type      string     `gorm:"column:type"`
  Data      string     `gorm:"column:data"`
  ReadAt    *time.Time `gorm:"column:read_at"`
  CreatedAt time.Time  `gorm:"column:created_at"`
}

func (Notification) TableName() string { return "notifications" }
go
package controllers

import (
  "github.com/goravel/framework/contracts/http"

  "goravel/app/facades"
  "goravel/app/models"
)

func (c *UserController) Notifications(ctx http.Context) http.Response {
  var notifications []models.Notification

  if err := facades.Orm().Query().Where("notifiable_id", userID).Find(&notifications); err != nil {
    return ctx.Response().String(http.StatusInternalServerError, err.Error())
  }

  return ctx.Response().Success().Json(http.Json{"notifications": notifications})
}

Queued Notifications

Send runs the delivery inline in the current request unless the notification opts into the queue. To make a notification queued, implement the ShouldQueue contract on it with OnQueue and OnConnection methods. Returning "" for either falls back to the default queue and connection:

go
package notifications

import (
  "github.com/goravel/framework/contracts/notification"
)

type OrderProcessed struct {
  OrderID string
}

// Via returns the channels this notification should be sent through.
func (r *OrderProcessed) Via(notifiable notification.Notifiable) []string {
  return []string{notification.ChannelDatabase}
}

func (r *OrderProcessed) ToDatabase(notifiable notification.Notifiable) map[string]any {
  return map[string]any{"order_id": r.OrderID}
}

// ShouldQueue marks this notification for queued dispatch.
func (r *OrderProcessed) OnQueue() string      { return "notifications" }
func (r *OrderProcessed) OnConnection() string { return "database" }

When you send a queued notification with Send (or Notify), it is enqueued as a job instead of being delivered immediately, and a queue worker must be running to deliver it. SendNow (or NotifyNow) always bypasses the queue and delivers synchronously.

go
// Queued: delivered by a queue worker.
err := facades.Notification().Send(user, notifications.NewOrderProcessed("12345"))

// Synchronous: delivered immediately, even for ShouldQueue notifications.
err := facades.Notification().SendNow(user, notifications.NewOrderProcessed("12345"))

A queued notification that declares no retry policy (no Tries, or Tries returning 0) is no longer single-shot: it falls back to the queue worker's Tries config (facades.Queue().Worker(queue.Args{Tries: ...})), so the worker determines how many attempts are made. A declared Backoff is honored on every retry — including worker-driven ones — with the last value repeating. A notification-declared Tries still overrides the worker's.

Custom Channels

When the built-in mail and database channels don't fit your use case — Slack, SMS, push, etc. — implement the Channel contract yourself and register it with Extend:

go
package channels

import (
  "github.com/goravel/framework/contracts/notification"
)

type SmsChannel struct{}

func (r *SmsChannel) Name() string { return "sms" }

func (r *SmsChannel) Send(notifiable notification.Notifiable, n notification.Notification) error {
  phone := notifiable.RouteNotificationFor("sms")
  // send the SMS...

  return nil
}

Register the channel on the manager, usually in a service provider Register method or at bootstrap:

go
facades.Notification().Extend(&channels.SmsChannel{})

Then return the channel name from a notification's Via method:

go
func (r *OrderShipped) Via(notifiable notification.Notifiable) []string {
  return []string{"sms"}
}

Queueable Custom Channels

To make a custom channel work with queued notifications, implement the ResolvableChannel contract. It splits delivery into Resolve — which captures the recipient route and the channel's message payload as plain JSON data while the live values are still in scope — and Deliver, which sends using only that plain data. This keeps the queued dispatch job fully serializable across persisting queue drivers like database and redis:

go
package channels

import (
  "encoding/json"

  "github.com/spf13/cast"

  "github.com/goravel/framework/contracts/notification"
)

type SmsChannel struct{}

func (r *SmsChannel) Name() string { return "sms" }

func (r *SmsChannel) Send(notifiable notification.Notifiable, n notification.Notification) error {
  route, payload, err := r.Resolve(notifiable, n)
  if err != nil {
    return err
  }

  return r.Deliver(route, payload)
}

func (r *SmsChannel) Resolve(notifiable notification.Notifiable, n notification.Notification) (route string, payload []byte, err error) {
  route = cast.ToString(notifiable.RouteNotificationFor("sms"))
  payload, err = json.Marshal(map[string]any{"message": "your order has shipped"})

  return route, payload, err
}

func (r *SmsChannel) Deliver(route string, payload []byte) error {
  // send the SMS using only the plain data...
  return nil
}

Sending to an unregistered channel name returns a NotificationChannelNotFound error, and queuing a channel that doesn't implement ResolvableChannel returns a NotificationChannelNotQueueable error.

Released under the MIT License