DevChalk

Goravel series

Events & Listeners: Notifications

Krishan Kumar · · 12 min See the changes ↗

Right now, when Ana gives Ben a task, nothing tells him about it. This part sends him a notification, but the task handler doesn’t write it itself: it announces the assignment as an event, TaskAssigned, and a separate listener writes the notification. The listener starts out inside the request and later moves to the background, so Ana’s request no longer waits for it.

CodeStart from the File Storage: Attachments code
Terminal window
git clone https://github.com/krishankumar01/goravel-taskflow
cd goravel-taskflow
git checkout part-14
cp .env.example .env
go run . artisan key:generate
go run . artisan jwt:secret
docker compose up -d
go run . artisan migrate:fresh --seed
go install github.com/air-verse/air@latest
air

Why an event

The simplest way would be to write the notification in the handler, right after the task is saved, and for one notification that’s fine. But an assignment usually leads to more than that, like an email or a message in a chat app. If each of those goes into the task handler, it keeps growing, and every request has to wait for all of them to finish.

An event splits that work up. The handler only announces what happened, that Ana assigned task 8 to Ben, and the listeners decide what to do about it. Sending an email as well would then mean adding one more listener, without touching the handler at all.

Installing events

A new Goravel app doesn’t have events yet. Install them like any other facade:

Terminal window
go run . artisan package:install Event
INFO Event depends on Log, Cache, Orm, Schema, DB, Queue, they will be installed simultaneously
SUCCESS Facade Event installed successfully

The queue is on that list because a listener can run on it. TaskFlow has had the queue since Installation & Project Architecture, so only the event pieces were added: app/facades/event.go, and the event service provider in bootstrap/providers.go.

The notifications table

This is the table Installation & Project Architecture planned. A notification is one row with who it is for, which task it is about and the text to show, plus read_at, which stays empty until the user has read it:

app/models/notification.go
package models
import (
"github.com/goravel/framework/database/orm"
"github.com/goravel/framework/support/carbon"
)
type Notification struct {
orm.Model
UserID uint `gorm:"not null" json:"user_id"`
TaskID uint `gorm:"not null" json:"task_id"`
Message string `gorm:"size:255;not null" json:"message"`
ReadAt *carbon.DateTime `json:"read_at"`
}

Register the model next to the others, as in File Storage: Attachments:

bootstrap/app.go
34 collapsed lines
package bootstrap
import (
"context"
"strings"
"github.com/goravel/framework/auth/access"
contractsaccess "github.com/goravel/framework/contracts/auth/access"
"github.com/goravel/framework/contracts/database/schema"
contractsfoundation "github.com/goravel/framework/contracts/foundation"
"github.com/goravel/framework/contracts/http"
"github.com/goravel/framework/foundation"
"github.com/goravel/framework/http/limit"
"taskflow/app/enums"
"taskflow/app/facades"
"taskflow/app/http/authuser"
"taskflow/app/models"
"taskflow/app/policies"
"taskflow/config"
"taskflow/routes"
)
func Boot() contractsfoundation.Application {
return foundation.Setup().
WithRules(Rules).
WithSeeders(Seeders).
WithMigrations(Migrations).
WithRouting(func() {
routes.Api()
routes.Web()
}).
WithProviders(Providers).
WithConfig(config.Boot).
WithCallback(func() {
facades.Schema().Extend(schema.Extension{
Models: []any{models.User{}, models.Task{}, models.Attachment{}},
Models: []any{models.User{}, models.Task{}, models.Attachment{}, models.Notification{}},
})
34 collapsed lines
facades.RateLimiter().For("login", func(ctx http.Context) http.Limit {
email := strings.ToLower(strings.TrimSpace(ctx.Request().Input("email")))
return limit.PerMinute(5).By(email).Response(func(ctx http.Context) {
ctx.Response().Json(429, map[string]any{
"error": "Too many login attempts. Try again in a minute.",
}).Abort()
})
})
facades.Gate().Before(func(ctx context.Context, ability string, arguments map[string]any) contractsaccess.Response {
if user := authuser.From(ctx); user != nil && user.Role == enums.RoleManager {
return access.NewAllowResponse()
}
return nil
})
facades.Gate().Define(policies.ViewUsers, func(ctx context.Context, arguments map[string]any) contractsaccess.Response {
return access.NewDenyResponse("Only managers can see the users.")
})
taskPolicy := policies.NewTaskPolicy()
facades.Gate().Define(policies.CreateTask, taskPolicy.Create)
facades.Gate().Define(policies.UpdateTask, taskPolicy.Update)
facades.Gate().Define(policies.DeleteTask, taskPolicy.Delete)
attachmentPolicy := policies.NewAttachmentPolicy()
facades.Gate().Define(policies.CreateAttachment, attachmentPolicy.Create)
facades.Gate().Define(policies.ViewAttachment, attachmentPolicy.View)
facades.Gate().Define(policies.DeleteAttachment, attachmentPolicy.Delete)
}).
Create()
}

Then generate the migration from the model:

Terminal window
go run . artisan make:migration create_notifications_table -m Notification

The columns come from the model, but the two foreign keys don’t, so add them. The one on task_id uses CascadeOnDelete, so when a task is deleted, Postgres deletes its notifications along with it. Without that, Postgres would refuse to delete any task that has a notification:

database/migrations/20261026214627_create_notifications_table.go
16 collapsed lines
package migrations
import (
"github.com/goravel/framework/contracts/database/schema"
"taskflow/app/facades"
)
type M20261026214627CreateNotificationsTable struct{}
// Signature The unique signature for the migration.
func (r *M20261026214627CreateNotificationsTable) Signature() string {
return "20261026214627_create_notifications_table"
}
// Up Run the migrations.
func (r *M20261026214627CreateNotificationsTable) Up() error {
if !facades.Schema().HasTable("notifications") {
return facades.Schema().Create("notifications", func(table schema.Blueprint) {
table.TimestampTz("created_at").Nullable()
table.TimestampTz("updated_at").Nullable()
table.BigIncrements("id")
table.UnsignedBigInteger("user_id")
table.UnsignedBigInteger("task_id")
table.String("message", 255)
table.TimestampTz("read_at").Nullable()
table.Foreign("user_id").References("id").On("users")
table.Foreign("task_id").References("id").On("tasks").CascadeOnDelete()
})
9 collapsed lines
}
return nil
}
// Down Reverse the migrations.
func (r *M20261026214627CreateNotificationsTable) Down() error {
return facades.Schema().DropIfExists("notifications")
}
Terminal window
go run . artisan migrate

The event

Generate the event:

Terminal window
go run . artisan make:event TaskAssigned

A Goravel event has two parts: a struct whose name says what happened, and a list of args with the details. TaskAssigned keeps its struct empty, for a reason the Gotcha below explains, so the details all go in the args: the task, who got it and who gave it:

app/events/task_assigned.go
package events
import (
"errors"
"github.com/goravel/framework/contracts/event"
)
type TaskAssigned struct {
}
func NewTaskAssigned(taskID, assignedTo, assignedBy uint) (*TaskAssigned, []event.Arg) {
return &TaskAssigned{}, []event.Arg{
{Type: "uint", Value: taskID},
{Type: "uint", Value: assignedTo},
{Type: "uint", Value: assignedBy},
}
}
func (receiver *TaskAssigned) Handle(args []event.Arg) ([]event.Arg, error) {
if len(args) != 3 {
return nil, errors.New("task assigned: needs the task id, the assignee id and the id of who assigned it")
}
for _, arg := range args {
if _, ok := arg.Value.(uint); !ok {
return nil, errors.New("task assigned: every id must be a uint")
}
}
return args, nil
}
  • NewTaskAssigned builds both parts at once, so every call passes all three ids, in the same order and as uints. When Ana gives task 8 to Ben, the call is NewTaskAssigned(8, 5, 1): task 8, Ben (user 5) and Ana (user 1).
  • Type: "uint" isn’t used yet. It matters once the listener runs on the queue, where the job is stored as JSON and the worker reads Type to turn 8 back into a uint.
  • Handle runs before any listener, so bad args fail right away at Dispatch, in the handler, and not later inside a listener. NewTaskAssigned always gets the args right, so the check is there for a call that builds them by hand.

The listener

Generate the listener:

Terminal window
go run . artisan make:listener NotifyAssignee

A listener is the code that reacts to an event. Here is NotifyAssignee:

app/listeners/notify_assignee.go
package listeners
import (
"errors"
"fmt"
"github.com/goravel/framework/contracts/event"
frameworkerrors "github.com/goravel/framework/errors"
"taskflow/app/facades"
"taskflow/app/models"
)
type NotifyAssignee struct {
}
func (receiver *NotifyAssignee) Signature() string {
return "notify_assignee"
}
func (receiver *NotifyAssignee) Queue(args ...any) event.Queue {
return event.Queue{
Enable: false,
Connection: "",
Queue: "",
}
}
func (receiver *NotifyAssignee) Handle(args ...any) error {
taskID, ok1 := args[0].(uint)
assignedTo, ok2 := args[1].(uint)
assignedBy, ok3 := args[2].(uint)
if !ok1 || !ok2 || !ok3 {
return errors.New("notify assignee: the ids must be uints")
}
if assignedTo == assignedBy {
return nil
}
var task models.Task
err := facades.Orm().Query().FindOrFail(&task, taskID)
if errors.Is(err, frameworkerrors.OrmRecordNotFound) {
return nil
}
if err != nil {
return err
}
var manager models.User
if err := facades.Orm().Query().FindOrFail(&manager, assignedBy); err != nil {
return err
}
return facades.Orm().Query().Create(&models.Notification{
UserID: assignedTo,
TaskID: task.ID,
Message: fmt.Sprintf("%s assigned you %q", manager.Name, task.Title),
})
}
  • Signature names the listener notify_assignee. Once the listener runs on the queue, the worker finds it by this name.
  • Queue returns Enable: false for now, so the listener runs right away, inside the request.
  • Handle writes the notification from the args, which arrive in the order the event put them in. It skips a manager who takes a task themselves, and it checks the type of every arg and returns an error instead of crashing; the Gotcha in the queue section shows why.

Firing the event

The event and the listener exist now, but nothing connects them yet. WithEvents in bootstrap/app.go does that, by listing each event with the listeners that react to it. One event can have several listeners, and in the request they run in the order listed here:

bootstrap/app.go
7 collapsed lines
package bootstrap
import (
"context"
"strings"
"github.com/goravel/framework/auth/access"
contractsaccess "github.com/goravel/framework/contracts/auth/access"
"github.com/goravel/framework/contracts/database/schema"
contractsevent "github.com/goravel/framework/contracts/event"
contractsfoundation "github.com/goravel/framework/contracts/foundation"
"github.com/goravel/framework/contracts/http"
"github.com/goravel/framework/foundation"
"github.com/goravel/framework/http/limit"
"taskflow/app/enums"
"taskflow/app/events"
"taskflow/app/facades"
"taskflow/app/http/authuser"
"taskflow/app/listeners"
"taskflow/app/models"
"taskflow/app/policies"
11 collapsed lines
"taskflow/config"
"taskflow/routes"
)
func Boot() contractsfoundation.Application {
return foundation.Setup().
WithRules(Rules).
WithSeeders(Seeders).
WithMigrations(Migrations).
WithRouting(func() {
routes.Api()
routes.Web()
}).
WithEvents(func() map[contractsevent.Event][]contractsevent.Listener {
return map[contractsevent.Event][]contractsevent.Listener{
&events.TaskAssigned{}: {&listeners.NotifyAssignee{}},
}
}).
WithProviders(Providers).
WithConfig(config.Boot).
39 collapsed lines
WithCallback(func() {
facades.Schema().Extend(schema.Extension{
Models: []any{models.User{}, models.Task{}, models.Attachment{}, models.Notification{}},
})
facades.RateLimiter().For("login", func(ctx http.Context) http.Limit {
email := strings.ToLower(strings.TrimSpace(ctx.Request().Input("email")))
return limit.PerMinute(5).By(email).Response(func(ctx http.Context) {
ctx.Response().Json(429, map[string]any{
"error": "Too many login attempts. Try again in a minute.",
}).Abort()
})
})
facades.Gate().Before(func(ctx context.Context, ability string, arguments map[string]any) contractsaccess.Response {
if user := authuser.From(ctx); user != nil && user.Role == enums.RoleManager {
return access.NewAllowResponse()
}
return nil
})
facades.Gate().Define(policies.ViewUsers, func(ctx context.Context, arguments map[string]any) contractsaccess.Response {
return access.NewDenyResponse("Only managers can see the users.")
})
taskPolicy := policies.NewTaskPolicy()
facades.Gate().Define(policies.CreateTask, taskPolicy.Create)
facades.Gate().Define(policies.UpdateTask, taskPolicy.Update)
facades.Gate().Define(policies.DeleteTask, taskPolicy.Delete)
attachmentPolicy := policies.NewAttachmentPolicy()
facades.Gate().Define(policies.CreateAttachment, attachmentPolicy.Create)
facades.Gate().Define(policies.ViewAttachment, attachmentPolicy.View)
facades.Gate().Define(policies.DeleteAttachment, attachmentPolicy.Delete)
}).
Create()
}

Gotcha Goravel finds an event’s listeners by its pointer, so the &TaskAssigned{} the handler fires has to match the one in WithEvents. They’re two separate values, and they only match because Go usually puts every empty struct at the same address. Add a field, like TaskID uint, and each one gets its own address, so Goravel finds no listeners:

event &{0} doesn't bind listeners

That’s why the struct stays empty and the ids go in the args.

Now the task handlers fire the event. A task gets an assignee in two places: when Ana creates it, in Store, and when she changes it, in Update. Both fire TaskAssigned only when someone new is assigned, so renaming a task sends nothing:

app/http/controllers/task_controller.go
package controllers
import (
"errors"
"fmt"
"taskflow/app/enums"
"taskflow/app/events"
"taskflow/app/facades"
"taskflow/app/http/authuser"
60 collapsed lines
"taskflow/app/http/requests"
"taskflow/app/models"
"taskflow/app/policies"
"github.com/goravel/framework/contracts/http"
frameworkerrors "github.com/goravel/framework/errors"
"github.com/goravel/framework/validation"
)
type TaskController struct {
// Dependent services
}
func NewTaskController() *TaskController {
return &TaskController{
// Inject services
}
}
func (r *TaskController) Store(ctx http.Context) http.Response {
if response := facades.Gate().WithContext(ctx).Inspect(policies.CreateTask, nil); !response.Allowed() {
return ctx.Response().Json(403, map[string]any{
"error": response.Message(),
})
}
var request requests.StoreTaskRequest
validationErrors, err := ctx.Request().ValidateRequest(&request)
if err != nil {
facades.Log().Error(err)
return ctx.Response().Json(500, map[string]any{
"error": "Could not save the task.",
})
}
if validationErrors != nil {
return ctx.Response().Json(422, map[string]any{
"errors": validationErrors.All(),
})
}
task := models.Task{
CreatedBy: authuser.From(ctx).ID,
AssignedTo: request.AssignedTo,
Title: request.Title,
Description: request.Description,
Status: enums.StatusPending,
DueAt: request.DueAt,
}
if err := facades.Orm().WithContext(ctx).Query().Create(&task); err != nil {
facades.Log().Error(err)
return ctx.Response().Json(500, map[string]any{
"error": "Could not save the task.",
})
}
var saved models.Task
err = facades.Orm().WithContext(ctx).Query().With("Creator").With("Assignee").Find(&saved, task.ID)
if err != nil {
facades.Log().Error(err)
return ctx.Response().Json(500, map[string]any{
"error": "Could not load the task.",
})
}
if saved.AssignedTo != nil {
err := facades.Event().Job(events.NewTaskAssigned(saved.ID, *saved.AssignedTo, authuser.From(ctx).ID)).Dispatch()
if err != nil {
facades.Log().Error(err)
}
}
return ctx.Response().Json(201, saved)
}
108 collapsed lines
func (r *TaskController) Show(ctx http.Context) http.Response {
var task models.Task
err := facades.Orm().WithContext(ctx).Query().
With("Creator").
With("Assignee").
With("Attachments").
FindOrFail(&task, ctx.Request().RouteInt("id"))
if errors.Is(err, frameworkerrors.OrmRecordNotFound) {
return ctx.Response().Json(404, map[string]any{
"error": "The task does not exist.",
})
}
if err != nil {
facades.Log().Error(err)
return ctx.Response().Json(500, map[string]any{
"error": "Could not load the task.",
})
}
return ctx.Response().Json(200, task)
}
func (r *TaskController) Index(ctx http.Context) http.Response {
validator, err := ctx.Request().Validate(map[string]any{
"status": "in:pending,in-progress,done",
"page": "bail|filled|integer|min:1",
"per_page": "bail|filled|integer|min:1|max:100",
})
if err != nil {
facades.Log().Error(err)
return ctx.Response().Json(500, map[string]any{
"error": "Could not list the tasks.",
})
}
if validator.Fails() {
return ctx.Response().Json(422, map[string]any{
"errors": validator.Errors().All(),
})
}
page := ctx.Request().QueryInt("page", 1)
perPage := ctx.Request().QueryInt("per_page", 15)
query := facades.Orm().WithContext(ctx).Query()
if status := ctx.Request().Query("status"); status != "" {
query = query.Where("status", status)
}
var tasks []models.Task
var total int64
err = query.With("Creator").With("Assignee").OrderByDesc("id").Paginate(page, perPage, &tasks, &total)
if err != nil {
facades.Log().Error(err)
return ctx.Response().Json(500, map[string]any{
"error": "Could not list the tasks.",
})
}
return ctx.Response().Json(200, map[string]any{
"data": tasks,
"meta": map[string]any{
"page": page,
"per_page": perPage,
"total": total,
},
})
}
func (r *TaskController) Update(ctx http.Context) http.Response {
var task models.Task
err := facades.Orm().WithContext(ctx).Query().FindOrFail(&task, ctx.Request().RouteInt("id"))
if errors.Is(err, frameworkerrors.OrmRecordNotFound) {
return ctx.Response().Json(404, map[string]any{
"error": "The task does not exist.",
})
}
if err != nil {
facades.Log().Error(err)
return ctx.Response().Json(500, map[string]any{
"error": "Could not load the task.",
})
}
var request requests.UpdateTaskRequest
if err := request.Authorize(ctx); err != nil {
facades.Log().Error(err)
return ctx.Response().Json(500, map[string]any{
"error": "Could not save the task.",
})
}
validator, err := ctx.Request().Validate(
request.Rules(ctx),
validation.Filters(request.Filters(ctx)),
validation.Attributes(request.Attributes(ctx)),
)
if err != nil {
facades.Log().Error(err)
return ctx.Response().Json(500, map[string]any{
"error": "Could not save the task.",
})
}
if validator.Fails() {
return ctx.Response().Json(422, map[string]any{
"errors": validator.Errors().All(),
})
}
changes := validator.Validated()
var assignedBefore uint
if task.AssignedTo != nil {
assignedBefore = *task.AssignedTo
}
response := facades.Gate().WithContext(ctx).Inspect(policies.UpdateTask, map[string]any{
"task": &task,
22 collapsed lines
"changes": changes,
})
if !response.Allowed() {
return ctx.Response().Json(403, map[string]any{
"error": response.Message(),
})
}
if len(changes) > 0 {
if _, err := facades.Orm().WithContext(ctx).Query().Model(&task).Update(changes); err != nil {
facades.Log().Error(err)
return ctx.Response().Json(500, map[string]any{
"error": "Could not save the task.",
})
}
}
var saved models.Task
err = facades.Orm().WithContext(ctx).Query().With("Creator").With("Assignee").Find(&saved, task.ID)
if err != nil {
facades.Log().Error(err)
return ctx.Response().Json(500, map[string]any{
"error": "Could not load the task.",
})
}
if saved.AssignedTo != nil && *saved.AssignedTo != assignedBefore {
err := facades.Event().Job(events.NewTaskAssigned(saved.ID, *saved.AssignedTo, authuser.From(ctx).ID)).Dispatch()
if err != nil {
facades.Log().Error(err)
}
}
return ctx.Response().Json(200, saved)
}
35 collapsed lines
func (r *TaskController) Destroy(ctx http.Context) http.Response {
var task models.Task
err := facades.Orm().WithContext(ctx).Query().FindOrFail(&task, ctx.Request().RouteInt("id"))
if errors.Is(err, frameworkerrors.OrmRecordNotFound) {
return ctx.Response().Json(404, map[string]any{
"error": "The task does not exist.",
})
}
if err != nil {
facades.Log().Error(err)
return ctx.Response().Json(500, map[string]any{
"error": "Could not load the task.",
})
}
if response := facades.Gate().WithContext(ctx).Inspect(policies.DeleteTask, nil); !response.Allowed() {
return ctx.Response().Json(403, map[string]any{
"error": response.Message(),
})
}
if _, err := facades.Orm().WithContext(ctx).Query().Delete(&task); err != nil {
facades.Log().Error(err)
return ctx.Response().Json(500, map[string]any{
"error": "Could not delete the task.",
})
}
if err := facades.Storage().DeleteDirectory(fmt.Sprintf("attachments/%d", task.ID)); err != nil {
facades.Log().Error(err)
}
return ctx.Response().NoContent()
}
  • Job(...) gets the event ready and Dispatch() sends it: first the event’s Handle runs, then each listener. NewTaskAssigned returns the event and its args, which is exactly what Job takes.
  • Update(changes) writes the new values into task itself, so the old assignee is copied into assignedBefore before the save. Without that copy, the handler couldn’t tell that the assignee changed.
  • If Dispatch returns an error, the task is already saved, so the handler only logs the error and still answers 201 or 200.

Reading notifications

Each user reads their own notifications at GET /notifications, newest first. They pile up, one for every assignment, so the list comes in pages, like the task list in Task CRUD. POST /notifications/{id}/read marks one as read:

app/http/controllers/notification_controller.go
24 collapsed lines
package controllers
import (
"errors"
"github.com/goravel/framework/contracts/http"
frameworkerrors "github.com/goravel/framework/errors"
"github.com/goravel/framework/support/carbon"
"taskflow/app/facades"
"taskflow/app/http/authuser"
"taskflow/app/models"
)
type NotificationController struct {
// Dependent services
}
func NewNotificationController() *NotificationController {
return &NotificationController{
// Inject services
}
}
func (r *NotificationController) Index(ctx http.Context) http.Response {
validator, err := ctx.Request().Validate(map[string]any{
"page": "bail|filled|integer|min:1",
"per_page": "bail|filled|integer|min:1|max:100",
16 collapsed lines
})
if err != nil {
facades.Log().Error(err)
return ctx.Response().Json(500, map[string]any{
"error": "Could not list the notifications.",
})
}
if validator.Fails() {
return ctx.Response().Json(422, map[string]any{
"errors": validator.Errors().All(),
})
}
page := ctx.Request().QueryInt("page", 1)
perPage := ctx.Request().QueryInt("per_page", 15)
var notifications []models.Notification
var total int64
err = facades.Orm().WithContext(ctx).Query().
Where("user_id", authuser.From(ctx).ID).
OrderByDesc("id").
Paginate(page, perPage, &notifications, &total)
16 collapsed lines
if err != nil {
facades.Log().Error(err)
return ctx.Response().Json(500, map[string]any{
"error": "Could not list the notifications.",
})
}
return ctx.Response().Json(200, map[string]any{
"data": notifications,
"meta": map[string]any{
"page": page,
"per_page": perPage,
"total": total,
},
})
}
func (r *NotificationController) Read(ctx http.Context) http.Response {
var notification models.Notification
err := facades.Orm().WithContext(ctx).Query().
Where("user_id", authuser.From(ctx).ID).
FindOrFail(&notification, ctx.Request().RouteInt("id"))
if errors.Is(err, frameworkerrors.OrmRecordNotFound) {
return ctx.Response().Json(404, map[string]any{
"error": "The notification does not exist.",
})
}
if err != nil {
facades.Log().Error(err)
return ctx.Response().Json(500, map[string]any{
"error": "Could not load the notification.",
})
}
if notification.ReadAt == nil {
notification.ReadAt = carbon.NewDateTime(carbon.Now().StartOfSecond())
if err := facades.Orm().WithContext(ctx).Query().Save(&notification); err != nil {
facades.Log().Error(err)
return ctx.Response().Json(500, map[string]any{
"error": "Could not save the notification.",
})
}
}
return ctx.Response().Json(200, notification)
}
  • Both handlers look only among the user’s own notifications, so when Read gets another user’s id, it answers the same 404 as for an id that doesn’t exist.
  • Read sets read_at only once, so it keeps when the user first read it. StartOfSecond drops the fraction of a second: the column keeps whole seconds and Postgres rounds, so without it the answer could show a different second from the one saved.
routes/api.go
14 collapsed lines
package routes
import (
"taskflow/app/facades"
"taskflow/app/http/controllers"
"taskflow/app/http/middleware"
"github.com/goravel/framework/contracts/route"
httpmiddleware "github.com/goravel/framework/http/middleware"
)
func Api() {
authController := controllers.NewAuthController()
taskController := controllers.NewTaskController()
userController := controllers.NewUserController()
attachmentController := controllers.NewAttachmentController()
notificationController := controllers.NewNotificationController()
facades.Route().Prefix("api/v1").Group(func(router route.Router) {
router.Post("register", authController.Register)
15 collapsed lines
router.Middleware(httpmiddleware.Throttle("login")).Post("login", authController.Login)
router.Post("refresh", authController.Refresh)
router.Middleware(&middleware.Auth{}).Group(func(router route.Router) {
router.Get("me", authController.Me)
router.Post("logout", authController.Logout)
router.Post("tasks", taskController.Store).Name("tasks.create")
router.Get("tasks/{id}", taskController.Show)
router.Get("tasks", taskController.Index)
router.Patch("tasks/{id}", taskController.Update)
router.Delete("tasks/{id}", taskController.Destroy)
router.Post("tasks/{id}/attachments", attachmentController.Store)
router.Get("tasks/{id}/attachments/{attachment}", attachmentController.Show)
router.Delete("tasks/{id}/attachments/{attachment}", attachmentController.Destroy)
router.Get("notifications", notificationController.Index)
router.Post("notifications/{id}/read", notificationController.Read)
router.Get("users", userController.Index)
router.Get("users/{id}", userController.Show)
})
})
}

See it work

Set up as in Gates & Policies, with one more member:

  1. Reset the database with migrate:fresh --seed. The seeder adds Ana, the manager, and three members.
  2. Register Ben and Cat. They get ids 5 and 6.
  3. Log in as Ana, Ben and Cat, and save each token in a variable: ANA, BEN and CAT.

Ana creates a task for Ben, and his notification is there as soon as the request returns. Cat can’t mark it as read, but Ben can:

Terminal window
curl -X POST http://localhost:3000/api/v1/tasks \
-H "Authorization: Bearer $ANA" \
-H "Content-Type: application/json" \
-d '{"title":"Fix the login page","description":"The error message overlaps the button.","assigned_to":5}'
{…,"id":8,…,"assigned_to":5,…,"title":"Fix the login page",…}
curl http://localhost:3000/api/v1/notifications -H "Authorization: Bearer $BEN"
{"data":[{…,"id":1,"user_id":5,"task_id":8,"message":"Ana assigned you \"Fix the login page\"","read_at":null}],"meta":{"page":1,"per_page":15,"total":1}}
curl -X POST http://localhost:3000/api/v1/notifications/1/read -H "Authorization: Bearer $CAT"
{"error":"The notification does not exist."}
curl -X POST http://localhost:3000/api/v1/notifications/1/read -H "Authorization: Bearer $BEN"
{…,"id":1,"user_id":5,"task_id":8,"message":"Ana assigned you \"Fix the login page\"","read_at":"2026-10-26 21:50:12"}

Then Ana renames the task, gives it to Cat, and finally takes it herself. Of those three changes, only giving it to Cat sends a notification:

Terminal window
curl -s -o /dev/null -w "%{http_code}\n" -X PATCH http://localhost:3000/api/v1/tasks/8 -H "Authorization: Bearer $ANA" -H "Content-Type: application/json" -d '{"title":"Fix the login form"}'
200
curl -s -o /dev/null -w "%{http_code}\n" -X PATCH http://localhost:3000/api/v1/tasks/8 -H "Authorization: Bearer $ANA" -H "Content-Type: application/json" -d '{"assigned_to":6}'
200
curl -s -o /dev/null -w "%{http_code}\n" -X PATCH http://localhost:3000/api/v1/tasks/8 -H "Authorization: Bearer $ANA" -H "Content-Type: application/json" -d '{"assigned_to":1}'
200
curl http://localhost:3000/api/v1/notifications -H "Authorization: Bearer $CAT"
{"data":[{…,"id":2,"user_id":6,"task_id":8,"message":"Ana assigned you \"Fix the login form\"","read_at":null}],"meta":{"page":1,"per_page":15,"total":1}}
curl http://localhost:3000/api/v1/notifications -H "Authorization: Bearer $ANA"
{"data":[],"meta":{"page":1,"per_page":15,"total":0}}

Ben still has only his first notification, now read.

MoreWhen a listener fails

Listeners in the request run one after another, and as soon as one returns an error, the ones after it are skipped. A listener that always fails makes this easy to watch. Make one with make:listener AlwaysFails, and have its Handle return an error:

func (receiver *AlwaysFails) Handle(args ...any) error {
return errors.New("this listener always fails")
}

Then put it in front of NotifyAssignee in WithEvents:

&events.TaskAssigned{}: {&listeners.AlwaysFails{}, &listeners.NotifyAssignee{}},

Ana gives Ben one more task. It is saved and she gets her 201, because the handler only logs the error, but NotifyAssignee never runs, so Ben’s list still has only his first notification:

Terminal window
curl -X POST http://localhost:3000/api/v1/tasks \
-H "Authorization: Bearer $ANA" \
-H "Content-Type: application/json" \
-d '{"title":"Update the README","description":"Add the new endpoints.","assigned_to":5}'
{…,"id":9,…,"assigned_to":5,…,"title":"Update the README",…}
curl http://localhost:3000/api/v1/notifications -H "Authorization: Bearer $BEN"
{"data":[{…,"id":1,"user_id":5,"task_id":8,"message":"Ana assigned you \"Fix the login page\"","read_at":"2026-10-26 21:50:12"}],"meta":{"page":1,"per_page":15,"total":1}}
grep local.error storage/logs/goravel-*.log
[2026-10-26 21:50:19.486] local.error: this listener always fails

Goravel’s docs call this “stopping the propagation”. On the queue, each listener is a job of its own, so if AlwaysFails were queued too, it would fail in the worker and NotifyAssignee would still run. Take AlwaysFails out of WithEvents again before going on.

Running the listener in the background

As long as the listener runs inside the request, Ana’s request waits for it. Writing one row is quick enough that she won’t notice, but an email can take seconds, and if the mail server is down, she’d be waiting on that too. A queue fixes this: the handler leaves a job on a list and answers right away, and a worker picks the job up a moment later and runs the listener.

TaskFlow already has both halves. The config/queue.go that came with the project sets default to redis. Of its three connections, sync runs a job straight away, inside the call, and database and redis keep jobs in Postgres or Redis:

config/queue.go
8 collapsed lines
package config
import (
"github.com/goravel/framework/contracts/queue"
redisfacades "github.com/goravel/redis/facades"
"taskflow/app/facades"
)
func init() {
config := facades.Config()
config.Add("queue", map[string]any{
"default": "redis",
// Queue Connections
//
// Here you may configure the connection information for each server that is used by your application.
// Drivers: "sync", "database", "custom"
"connections": map[string]any{
"sync": map[string]any{
"driver": "sync",
},
"database": map[string]any{
"driver": "database",
"connection": config.Env("DB_CONNECTION"),
"queue": "default",
"concurrent": 1,
},
"redis": map[string]any{
"driver": "custom",
"connection": "default",
"queue": "default",
"via": func() (queue.Driver, error) {
return redisfacades.Queue("redis") // The `redis` value is the key of `connections`
},
},
},
11 collapsed lines
// Failed Queue Jobs
//
// These options configure the behavior of failed queue job logging so you
// can control how and where failed jobs are stored.
"failed": map[string]any{
"database": config.Env("DB_CONNECTION"),
"table": "failed_jobs",
},
})
}

Whenever the default isn’t sync, app.Start() also runs a worker next to the HTTP server, which is the line air prints when it starts:

INFO Processing jobs from [redis] connection and [default] queue

A listener’s Queue method decides whether it uses that queue:

  • Enable puts the listener on the queue when it is true.
  • Connection picks one of the connections above. Empty means the default one.
  • Queue picks a queue on that connection, for example to keep slow jobs apart from quick ones. Empty means the default connection’s queue, which is default.

So to put NotifyAssignee on the queue, only Enable changes:

app/listeners/notify_assignee.go
20 collapsed lines
package listeners
import (
"errors"
"fmt"
"github.com/goravel/framework/contracts/event"
frameworkerrors "github.com/goravel/framework/errors"
"taskflow/app/facades"
"taskflow/app/models"
)
type NotifyAssignee struct {
}
func (receiver *NotifyAssignee) Signature() string {
return "notify_assignee"
}
func (receiver *NotifyAssignee) Queue(args ...any) event.Queue {
return event.Queue{
Enable: false,
Enable: true,
Connection: "",
Queue: "",
}
33 collapsed lines
}
func (receiver *NotifyAssignee) Handle(args ...any) error {
taskID, ok1 := args[0].(uint)
assignedTo, ok2 := args[1].(uint)
assignedBy, ok3 := args[2].(uint)
if !ok1 || !ok2 || !ok3 {
return errors.New("notify assignee: the ids must be uints")
}
if assignedTo == assignedBy {
return nil
}
var task models.Task
err := facades.Orm().Query().FindOrFail(&task, taskID)
if errors.Is(err, frameworkerrors.OrmRecordNotFound) {
return nil
}
if err != nil {
return err
}
var manager models.User
if err := facades.Orm().Query().FindOrFail(&manager, assignedBy); err != nil {
return err
}
return facades.Orm().Query().Create(&models.Notification{
UserID: assignedTo,
TaskID: task.ID,
Message: fmt.Sprintf("%s assigned you %q", manager.Name, task.Title),
})
}

Ana gives Ben a third task, and Ben checks his list straight away and again a second later. Paste the four commands together, because the worker looks for new jobs about once a second, and typed by hand, even the first check would come too late:

Terminal window
curl -X POST http://localhost:3000/api/v1/tasks \
-H "Authorization: Bearer $ANA" \
-H "Content-Type: application/json" \
-d '{"title":"Write the API docs","description":"Start with the auth routes.","assigned_to":5}'
{…,"assigned_to":5,…,"title":"Write the API docs",…}
curl http://localhost:3000/api/v1/notifications -H "Authorization: Bearer $BEN"
{"data":[{…,"id":1,…,"message":"Ana assigned you \"Fix the login page\"",…}],"meta":{"page":1,"per_page":15,"total":1}}
sleep 1
curl http://localhost:3000/api/v1/notifications -H "Authorization: Bearer $BEN"
{"data":[{…,"id":3,…,"message":"Ana assigned you \"Write the API docs\"","read_at":null},{…,"id":1,…}],"meta":{"page":1,"per_page":15,"total":2}}

Ana’s request came back before the listener ran, and the worker prints the job it ran a moment later, under the listener’s signature:

2026-10-26 21:50:26.42 notify_assignee ............................. RUNNING
2026-10-26 21:50:26.428 notify_assignee ....................... 8.159ms DONE

Three things change once a listener is on the queue:

  • Errors no longer reach the handler. Dispatch only fails if the job can’t be put on the queue, so if the listener fails later, only the worker knows.
  • The args must be plain values. The job is saved in Redis as JSON, and the worker rebuilds each value from its Type. That works for plain values like numbers and strings, which is why the event passes ids and not a Task or a User.
  • The world may change before the job runs. By then the task may have been renamed, given to someone else or deleted. That’s why the listener takes the assignee from the args, which say who was assigned at that moment, and why it stops quietly, without an error, when the task is gone.

Gotcha A panic in a queued listener stops the whole app. The worker runs inside the same process as the HTTP server and doesn’t recover from a panic, so an unchecked args[0].(int) on a uint brings both down:

panic: interface conversion: interface {} is uint, not int

The process exits, so the API is down until it’s started again. That’s why the listener checks every type and returns an error instead.

MoreWhy not a model observer

Goravel can also run code whenever a model is saved, with an observer. A TaskObserver’s Created and Updated methods could write the notification when assigned_to is set or changes. But an observer fires on every save, wherever it comes from, so the seeders would send notifications too, and it runs inside the save, not on the queue. Only the task handler knows “Ana just gave this task to Ben”, so TaskFlow fires the event there.

Here is the whole trip, first with the listener in the request, then on the queue:

Two assignments: the first with the listener in the request, the second on the queue.

Note Goravel 1.19 is set to add its own notifications. Until then, an event and a listener do the job.

Tests

The tests for this part are in tests/feature/notification_test.go in TaskFlow. They check who gets a notification after each kind of change, that users only see and mark their own, that deleting a task removes its notifications, and that the event turns down bad args.

The tests don’t start the app, so no worker runs, and a queued listener would never run at all. The sync connection runs it straight away instead, so the queue’s default now comes from an env variable, and the tests set it to sync:

config/queue.go
8 collapsed lines
package config
import (
"github.com/goravel/framework/contracts/queue"
redisfacades "github.com/goravel/redis/facades"
"taskflow/app/facades"
)
func init() {
config := facades.Config()
config.Add("queue", map[string]any{
"default": "redis",
"default": config.Env("QUEUE_CONNECTION", "redis"),
34 collapsed lines
// Queue Connections
//
// Here you may configure the connection information for each server that is used by your application.
// Drivers: "sync", "database", "custom"
"connections": map[string]any{
"sync": map[string]any{
"driver": "sync",
},
"database": map[string]any{
"driver": "database",
"connection": config.Env("DB_CONNECTION"),
"queue": "default",
"concurrent": 1,
},
"redis": map[string]any{
"driver": "custom",
"connection": "default",
"queue": "default",
"via": func() (queue.Driver, error) {
return redisfacades.Queue("redis") // The `redis` value is the key of `connections`
},
},
},
// Failed Queue Jobs
//
// These options configure the behavior of failed queue job logging so you
// can control how and where failed jobs are stored.
"failed": map[string]any{
"database": config.Env("DB_CONNECTION"),
"table": "failed_jobs",
},
})
}

.env.example gets the line with redis, and an .env without it falls back to redis too:

.env.example
23 collapsed lines
APP_NAME=Goravel
APP_ENV=local
APP_KEY=
APP_DEBUG=true
LOG_CHANNEL=stack
LOG_LEVEL=debug
SESSION_DRIVER=file
SESSION_LIFETIME=120
APP_URL=http://localhost
APP_HOST=127.0.0.1
APP_PORT=3000
JWT_SECRET=
DB_CONNECTION=postgres
DB_HOST=127.0.0.1
DB_PORT=5433
DB_DATABASE=taskflow
DB_USERNAME=goravel
DB_PASSWORD=secret
FILESYSTEM_DISK=local
QUEUE_CONNECTION=redis
REDIS_HOST=127.0.0.1
REDIS_PASSWORD=
REDIS_PORT=6380

and the tests get sync:

.env.testing
29 collapsed lines
APP_NAME=Goravel
APP_ENV=testing
APP_KEY=2Nr9bpCIal8Dj0qYE7IviHcYcwzmVqoh
APP_DEBUG=true
LOG_CHANNEL=stack
LOG_LEVEL=debug
SESSION_DRIVER=file
SESSION_LIFETIME=120
APP_URL=http://localhost
APP_HOST=127.0.0.1
APP_PORT=3000
JWT_SECRET=xRQKZ4WHIu8bIHANdQVnskdTsa47cya8
DB_CONNECTION=postgres
DB_HOST=127.0.0.1
DB_PORT=5433
DB_DATABASE=taskflow_test
DB_USERNAME=goravel
DB_PASSWORD=secret
REDIS_HOST=127.0.0.1
REDIS_PASSWORD=
REDIS_PORT=6380
CACHE_STORE=memory
HASH_ROUNDS=4
FILESYSTEM_DISK=testing
QUEUE_CONNECTION=sync

If the tests ran on redis, their jobs would wait in the shared Redis, and a running server could take them and write into the wrong database, so the test case refuses any queue but sync, the same way it refuses the wrong disk:

tests/test_case.go
19 collapsed lines
package tests
import (
"strings"
"github.com/goravel/framework/testing"
"taskflow/app/facades"
"taskflow/bootstrap"
)
func init() {
bootstrap.Boot()
if !strings.HasSuffix(facades.Config().GetString("database.connections.postgres.database"), "_test") {
panic("the tests drop every table, so they only run on a database whose name ends in _test: go test ./tests/... --env=.env.testing")
}
if facades.Config().GetString("filesystems.default") != "testing" {
panic("the tests delete uploaded files, so they only run on the testing disk: go test ./tests/... --env=.env.testing")
}
if facades.Config().GetString("queue.default") != "sync" {
panic("the tests check what queued listeners wrote, so they only run on the sync queue: go test ./tests/... --env=.env.testing")
}
}
type TestCase struct {
testing.TestCase
}
Terminal window
go test ./tests/... --env=.env.testing
? taskflow/tests [no test files]
ok taskflow/tests/feature 1.299s
ok taskflow/tests/unit 0.401s

Assigning a task now sends a notification, written by a listener that runs on the queue, while the task handler only says what happened.