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.
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:
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:
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:
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:
returnnil, 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 {
returnnil, 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
gorun.artisanmake:listenerNotifyAssignee
A listener is the code that reacts to an event. Here is NotifyAssignee:
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:
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:
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:
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 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.
{"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":"Thenotificationdoesnotexist."}
curl -X POST http://localhost:3000/api/v1/notifications/1/read -H "Authorization:Bearer $BEN"
{"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}}
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:
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-XPOSThttp://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",…}
{"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
packageconfig
import(
"github.com/goravel/framework/contracts/queue"
redisfacades "github.com/goravel/redis/facades"
"taskflow/app/facades"
)
funcinit(){
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:
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-XPOSThttp://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",…}
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:
// 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: