# Events & Listeners

> A notification when a task is assigned.

Goravel series, part 15 of 20. By Krishan Kumar. Published 2026-10-27.
Source: https://devchalk.com/goravel/15-events-listeners-notifications
Code: https://github.com/krishankumar01/goravel-taskflow/tree/part-15

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.

Start from the File Storage code:

```bash
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
docker compose exec postgres psql -U goravel -d taskflow -c "CREATE DATABASE taskflow_test"
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](https://www.goravel.dev/digging-deeper/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:

```bash
$ 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](https://devchalk.com/goravel/01-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](https://devchalk.com/goravel/01-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:

```go title="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](https://devchalk.com/goravel/14-file-storage-attachments#the-attachments-table):

```diff lang="go" title="bootstrap/app.go" collapse={1-34} collapse={41-74}
 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{}},
 			})
 
 			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:

```bash
$ 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:

```go title="database/migrations/20261026214627_create_notifications_table.go" collapse={1-16} collapse={31-39}
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()
		})
	}

	return nil
}

// Down Reverse the migrations.
func (r *M20261026214627CreateNotificationsTable) Down() error {
	return facades.Schema().DropIfExists("notifications")
}
```

```bash
$ go run . artisan migrate
```

## The event

Generate the event:

```bash
$ 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:

```go title="app/events/task_assigned.go" t1:event=/return (&TaskAssigned[{][}])/ t2:ids=/NewTaskAssigned[(](taskID, assignedTo, assignedBy uint)[)]/
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 `uint`s. 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](#running-the-listener-in-the-background), 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:

```bash
$ go run . artisan make:listener NotifyAssignee
```

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

```go title="app/listeners/notify_assignee.go" t2:ids=/Handle[(](args [.][.][.]any)[)] error/ t3:sig=/"(notify_assignee)"/
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](#running-the-listener-in-the-background) 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:

```diff lang="go" title="bootstrap/app.go" collapse={1-7} collapse={23-33} collapse={43-81} t1:event=/(&events.TaskAssigned[{][}]):/
 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"
 	"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).
 		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:
>
> ```text
> 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:

```diff lang="go" title="app/http/controllers/task_controller.go" collapse={11-70} collapse={84-191} collapse={200-221} collapse={235-269}
 package controllers
 
 import (
 	"errors"
 	"fmt"
 
 	"taskflow/app/enums"
+	"taskflow/app/events"
 	"taskflow/app/facades"
 	"taskflow/app/http/authuser"
 	"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)
 }
 
 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,
 		"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)
 }
 
 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](https://devchalk.com/goravel/11-task-crud). `POST /notifications/{id}/read` marks one as read:

```go title="app/http/controllers/notification_controller.go" collapse={1-24} collapse={29-44} collapse={51-66}
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",
	})
	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)
	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.

```diff lang="go" title="routes/api.go" collapse={1-14} collapse={21-35}
 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)
 		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](https://devchalk.com/goravel/12-gates-policies#the-rules), 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:

```bash
$ 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:

```bash
$ 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.

When 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:

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

Then put it in front of `NotifyAssignee` in `WithEvents`:

```go
&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:

```bash
$ 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](https://www.goravel.dev/digging-deeper/event#stopping-the-propagation-of-an-event)". 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:

```go title="config/queue.go" collapse={1-8} collapse={37-47}
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`
				},
			},
		},

		// 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:

```text
  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:

```diff lang="go" title="app/listeners/notify_assignee.go" collapse={1-20} collapse={28-60}
 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:      "",
 	}
 }
 
 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:

```bash
$ 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:

```text t3:sig=/(notify_assignee) [.]+ RUNNING/
  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:
>
> ```text
> 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.

Why not a model observer:

Goravel can also run code whenever a model is saved, with an [observer](https://www.goravel.dev/orm/getting-started#observers). 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.*

In the request: the client sends the new task, the API saves it in Postgres, fires TaskAssigned, NotifyAssignee inserts the notification, and only then does the API answer 201. On the queue: the API saves the task, puts a notify_assignee job on Redis and answers 201 at once. The worker takes the job from Redis and NotifyAssignee inserts the notification a moment later.

> **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`](https://github.com/krishankumar01/goravel-taskflow/blob/part-15/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`:

```diff lang="go" title="config/queue.go" collapse={1-8} collapse={15-48}
 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"),
 
 		// 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:

```diff lang="bash" title=".env.example" collapse={1-23}
 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`:

```diff lang="bash" title=".env.testing" collapse={1-29}
 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:

```diff lang="go" title="tests/test_case.go" collapse={1-19}
 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
 }
```

```bash
$ 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.

---

Previous: [14. File Storage](https://devchalk.com/goravel/14-file-storage-attachments.md)
Next: [16. Queues & Jobs](https://devchalk.com/goravel/16-queues-jobs-task-export.md)
Series: [Goravel](https://devchalk.com/goravel.md)
