Part 15 of the Goravel series. The original on DevChalk has the colour-matched code, the figures and the full diffs. Code for this part: 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:
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 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:
$ 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:
bootstrap/app.go
// ... 34 lines not shown 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 lines not shown Then generate the migration from the model:
$ 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 lines not shown 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 lines not shown $ go run . artisan migrate The event
Generate the event:
$ 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 } -
NewTaskAssignedbuilds both parts at once, so every call passes all three ids, in the same order and asuints. When Ana gives task 8 to Ben, the call isNewTaskAssigned(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 readsTypeto turn8back into auint. -
Handleruns before any listener, so bad args fail right away atDispatch, in the handler, and not later inside a listener.NewTaskAssignedalways gets the args right, so the check is there for a call that builds them by hand.
The listener
Generate the listener:
$ 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), }) } -
Signaturenames the listenernotify_assignee. Once the listener runs on the queue, the worker finds it by this name. -
QueuereturnsEnable: falsefor now, so the listener runs right away, inside the request. -
Handlewrites 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 lines not shown 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 lines not shown 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 lines not shown Gotcha Goravel finds an event's listeners by its pointer, so the
&TaskAssigned{}the handler fires has to match the one inWithEvents. They're two separate values, and they only match because Go usually puts every empty struct at the same address. Add a field, likeTaskID uint, and each one gets its own address, so Goravel finds no listeners:event &{0} doesn't bind listenersThat'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 lines not shown "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 lines not shown 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 lines not shown "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 lines not shown -
Job(...)gets the event ready andDispatch()sends it: first the event'sHandleruns, then each listener.NewTaskAssignedreturns the event and its args, which is exactly whatJobtakes. -
Update(changes)writes the new values intotaskitself, so the old assignee is copied intoassignedBeforebefore the save. Without that copy, the handler couldn't tell that the assignee changed. - If
Dispatchreturns an error, the task is already saved, so the handler only logs the error and still answers201or200.
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 lines not shown 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 lines not shown 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, ¬ifications, &total) // ... 16 lines not shown 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(¬ification, 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(¬ification); 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
Readgets another user's id, it answers the same404as for an id that doesn't exist. -
Readsetsread_atonly once, so it keeps when the user first read it.StartOfSeconddrops 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 lines not shown 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 lines not shown 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:
- Reset the database with
migrate:fresh --seed. The seeder adds Ana, the manager, and three members. - Register Ben and Cat. They get ids 5 and 6.
- Log in as Ana, Ben and Cat, and save each token in a variable:
ANA,BENandCAT.
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:
$ 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:
$ 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:
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:
$ 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....