Relationships: Has Many
Users loaded with the tasks they made and the tasks assigned to them
In Relationships: Belongs To, each task loaded the user who made it and the user it is assigned to. In this part we go the other way, from a user to their tasks. We add a route that shows one user with their tasks, then a route that lists every user with their tasks, and use the query log to find and fix the N+1 problem.
CodeStart from the Relationships: Belongs To code
git clone https://github.com/krishankumar01/goravel-taskflowcd goravel-taskflowgit checkout part-05cp .env.example .envgo run . artisan key:generatedocker compose up -dgo run . artisan migratedocker compose exec postgres psql -U goravel -d taskflow \ -c "INSERT INTO users (name, email, password, role) VALUES ('Ana', 'ana@example.com', 'x', 'manager'), ('Ben', 'ben@example.com', 'x', 'member');" \ -c "INSERT INTO tasks (created_by, assigned_to, title, description, due_at) VALUES (1, 2, 'Ship v1', 'The first release', '2026-11-02 09:00:00+00'), (1, NULL, 'Write the docs', 'Nobody has it yet', NULL);"The three tasks you add below get ids 3 to 5, not 4 to 6.
Has many relationships
A task belongs to a user because the task row holds the user’s id. From the user’s side, that same link is called has many, because one user can have many tasks. A user has two such lists, the tasks they made and the tasks assigned to them. Add both:
13 collapsed lines
package models
import ( "github.com/goravel/framework/database/orm")
type Role string
const ( RoleManager Role = "manager" RoleMember Role = "member")
type User struct { orm.Model Name string `gorm:"size:255;not null" json:"name"` Email string `gorm:"size:255;unique;not null" json:"email"` Password string `gorm:"size:255;not null" json:"-"` Role Role `gorm:"type:enum('manager','member');default:'member';not null" json:"role"`
CreatedTasks []Task `gorm:"foreignKey:CreatedBy" json:"created_tasks"` AssignedTasks []Task `gorm:"foreignKey:AssignedTo" json:"assigned_tasks"`}CreatedTasksholds the tasks the user made. ItsforeignKey:CreatedBynames the field onTaskthat holds this user’s id.AssignedTasksholds the tasks assigned to the user, linked byAssignedTo.- Without
foreignKey, GORM looks for aUserIDfield onTask. There is none, so every query that reads users fails, evenGET /tasks/1. - They are slices, not pointers like
Creator, because a user can have many tasks.
There is no migration this time. The ids already live in the tasks table, so the users table needs nothing new.
Code The code for every step of this part is in the tag
part-06.
Test data
Right now there are two tasks, and only one of them is assigned to anyone, so a list of users would not show much. Add a third user, Cleo, and three more tasks, all made by Ana. One of the tasks assigned to Ben is done. We use it later, when we leave done tasks out:
docker compose exec postgres psql -U goravel -d taskflow \ -c "INSERT INTO users (name, email, password, role) VALUES ('Cleo', 'cleo@example.com', 'x', 'member');"INSERT 0 1docker compose exec postgres psql -U goravel -d taskflow \ -c "INSERT INTO tasks (created_by, assigned_to, title, description, status) VALUES (1, 2, 'Fix the login bug', 'Users get logged out', 'in-progress'), (1, 3, 'Review the API', 'Check every endpoint', 'pending'), (1, 2, 'Plan the next release', 'Pick what goes in', 'done');"INSERT 0 3Three tasks are now assigned to Ben, and one to Cleo.
Loading a has many relation
Add a route that loads a user with both lists:
package routes
import ( "taskflow/app/facades" "taskflow/app/models"
"github.com/goravel/framework/contracts/http" "github.com/goravel/framework/contracts/route")
44 collapsed lines
type CreateTaskRequest struct { Title string `form:"title" json:"title"` Description string `form:"description" json:"description"`}
func Api() { facades.Route().Prefix("api/v1").Group(func(router route.Router) { router.Post("tasks", func(ctx http.Context) http.Response { var request CreateTaskRequest if err := ctx.Request().Bind(&request); err != nil { return ctx.Response().Json(400, map[string]any{ "error": err.Error(), }) }
return ctx.Response().Json(200, request) }).Name("tasks.create")
router.Get("tasks/{id}", func(ctx http.Context) http.Response { var task models.Task err := facades.Orm().Query(). With("Creator"). With("Assignee"). Find(&task, ctx.Request().RouteInt("id")) if err != nil { return ctx.Response().Json(500, map[string]any{ "error": err.Error(), }) }
return ctx.Response().Json(200, task) })
router.Get("tasks", func(ctx http.Context) http.Response { status := ctx.Request().Query("status") page := ctx.Request().QueryInt("page", 1) filters := ctx.Request().QueryMap("filters")
return ctx.Response().Json(200, map[string]any{ "status": status, "page": page, "filters": filters, }) })
router.Get("users/{id}", func(ctx http.Context) http.Response { var user models.User err := facades.Orm().Query(). With("CreatedTasks"). With("AssignedTasks"). Find(&user, ctx.Request().RouteInt("id")) if err != nil { return ctx.Response().Json(500, map[string]any{ "error": err.Error(), }) }
return ctx.Response().Json(200, user) }) })}Add a request for Ben, user 2:
9 collapsed lines
### Create taskPOST http://localhost:3000/api/v1/tasksContent-Type: application/json
{ "title": "Ship v1", "description": "The first release"}
### Show taskGET http://localhost:3000/api/v1/tasks/1
### List tasksGET http://localhost:3000/api/v1/tasks?status=done&page=2&filters[priority]=high
### Show userGET http://localhost:3000/api/v1/users/2
### HelloGET http://localhost:3000/helloIf air is not running, start it, as in Routing & Response. Then send Show user. Here is the response, formatted:
{ "created_at": null, "updated_at": null, "id": 2, "name": "Ben", "email": "ben@example.com", "role": "member", "created_tasks": [], "assigned_tasks": [ { "created_at": null, "updated_at": null, "id": 1, "created_by": 1, "creator": null, "assigned_to": 2, "assignee": null, "title": "Ship v1", "description": "The first release", "status": "pending", "due_at": "2026-11-02 09:00:00" },26 collapsed lines
{ "created_at": null, "updated_at": null, "id": 4, "created_by": 1, "creator": null, "assigned_to": 2, "assignee": null, "title": "Fix the login bug", "description": "Users get logged out", "status": "in-progress", "due_at": null }, { "created_at": null, "updated_at": null, "id": 6, "created_by": 1, "creator": null, "assigned_to": 2, "assignee": null, "title": "Plan the next release", "description": "Pick what goes in", "status": "done", "due_at": null } ]}Ben made no tasks, so created_tasks is an empty list. He has three in assigned_tasks. Inside each task, creator and assignee are null, because this route does not load them.
The query log shows one query for the user and one for each list:
[2026-10-23 07:49:41.261] local.info: [1.316ms] [rows:3] SELECT * FROM "tasks" WHERE "tasks"."assigned_to" = 2[2026-10-23 07:49:41.262] local.info: [0.691ms] [rows:0] SELECT * FROM "tasks" WHERE "tasks"."created_by" = 2[2026-10-23 07:49:41.262] local.info: [6.399ms] [rows:1] SELECT * FROM "users" WHERE "users"."id" = 2Loading relations for a list
Add a route that lists every user. Leave out With for now, to see what an unloaded list looks like:
package routes
import ( "taskflow/app/facades" "taskflow/app/models"
"github.com/goravel/framework/contracts/http" "github.com/goravel/framework/contracts/route")
44 collapsed lines
type CreateTaskRequest struct { Title string `form:"title" json:"title"` Description string `form:"description" json:"description"`}
func Api() { facades.Route().Prefix("api/v1").Group(func(router route.Router) { router.Post("tasks", func(ctx http.Context) http.Response { var request CreateTaskRequest if err := ctx.Request().Bind(&request); err != nil { return ctx.Response().Json(400, map[string]any{ "error": err.Error(), }) }
return ctx.Response().Json(200, request) }).Name("tasks.create")
router.Get("tasks/{id}", func(ctx http.Context) http.Response { var task models.Task err := facades.Orm().Query(). With("Creator"). With("Assignee"). Find(&task, ctx.Request().RouteInt("id")) if err != nil { return ctx.Response().Json(500, map[string]any{ "error": err.Error(), }) }
return ctx.Response().Json(200, task) })
router.Get("tasks", func(ctx http.Context) http.Response { status := ctx.Request().Query("status") page := ctx.Request().QueryInt("page", 1) filters := ctx.Request().QueryMap("filters")
return ctx.Response().Json(200, map[string]any{ "status": status, "page": page, "filters": filters, }) })
router.Get("users", func(ctx http.Context) http.Response { var users []models.User err := facades.Orm().Query().Find(&users) if err != nil { return ctx.Response().Json(500, map[string]any{ "error": err.Error(), }) }
return ctx.Response().Json(200, users) })
16 collapsed lines
router.Get("users/{id}", func(ctx http.Context) http.Response { var user models.User err := facades.Orm().Query(). With("CreatedTasks"). With("AssignedTasks"). Find(&user, ctx.Request().RouteInt("id")) if err != nil { return ctx.Response().Json(500, map[string]any{ "error": err.Error(), }) }
return ctx.Response().Json(200, user) }) })}Add a request for it:
9 collapsed lines
### Create taskPOST http://localhost:3000/api/v1/tasksContent-Type: application/json
{ "title": "Ship v1", "description": "The first release"}
### Show taskGET http://localhost:3000/api/v1/tasks/1
### List tasksGET http://localhost:3000/api/v1/tasks?status=done&page=2&filters[priority]=high
### List usersGET http://localhost:3000/api/v1/users
### Show userGET http://localhost:3000/api/v1/users/2
### HelloGET http://localhost:3000/helloSend List users. Here is the response, formatted:
[ { "created_at": null, "updated_at": null, "id": 1, "name": "Ana", "email": "ana@example.com", "role": "manager", "created_tasks": null, "assigned_tasks": null }, { "created_at": null, "updated_at": null, "id": 2, "name": "Ben", "email": "ben@example.com", "role": "member", "created_tasks": null, "assigned_tasks": null }, { "created_at": null, "updated_at": null, "id": 3, "name": "Cleo", "email": "cleo@example.com", "role": "member", "created_tasks": null, "assigned_tasks": null }]Every list is null, which means it was not loaded. [], like Ben’s created_tasks above, means it was loaded and is empty. As with Creator, reading user.AssignedTasks later does not fetch it. If you forget With, you get null and no error.
The N+1 problem
Goravel has Load, which loads a relation onto a model you already have. Calling it in a loop is a common first try. Load the assigned tasks, the work each user has to do:
package routes
import ( "taskflow/app/facades" "taskflow/app/models"
"github.com/goravel/framework/contracts/http" "github.com/goravel/framework/contracts/route")
45 collapsed lines
type CreateTaskRequest struct { Title string `form:"title" json:"title"` Description string `form:"description" json:"description"`}
func Api() { facades.Route().Prefix("api/v1").Group(func(router route.Router) { router.Post("tasks", func(ctx http.Context) http.Response { var request CreateTaskRequest if err := ctx.Request().Bind(&request); err != nil { return ctx.Response().Json(400, map[string]any{ "error": err.Error(), }) }
return ctx.Response().Json(200, request) }).Name("tasks.create")
router.Get("tasks/{id}", func(ctx http.Context) http.Response { var task models.Task err := facades.Orm().Query(). With("Creator"). With("Assignee"). Find(&task, ctx.Request().RouteInt("id")) if err != nil { return ctx.Response().Json(500, map[string]any{ "error": err.Error(), }) }
return ctx.Response().Json(200, task) })
router.Get("tasks", func(ctx http.Context) http.Response { status := ctx.Request().Query("status") page := ctx.Request().QueryInt("page", 1) filters := ctx.Request().QueryMap("filters")
return ctx.Response().Json(200, map[string]any{ "status": status, "page": page, "filters": filters, }) })
router.Get("users", func(ctx http.Context) http.Response { var users []models.User err := facades.Orm().Query().Find(&users) if err != nil { return ctx.Response().Json(500, map[string]any{ "error": err.Error(), }) }
for i := range users { if err := facades.Orm().Query().Load(&users[i], "AssignedTasks"); err != nil { return ctx.Response().Json(500, map[string]any{ "error": err.Error(), }) } }
return ctx.Response().Json(200, users) })17 collapsed lines
router.Get("users/{id}", func(ctx http.Context) http.Response { var user models.User err := facades.Orm().Query(). With("CreatedTasks"). With("AssignedTasks"). Find(&user, ctx.Request().RouteInt("id")) if err != nil { return ctx.Response().Json(500, map[string]any{ "error": err.Error(), }) }
return ctx.Response().Json(200, user) }) })}Send List users again. It works, but look at the log:
[2026-10-23 07:50:15.892] local.info: [5.191ms] [rows:3] SELECT * FROM "users"[2026-10-23 07:50:15.894] local.info: [1.312ms] [rows:0] SELECT * FROM "tasks" WHERE "tasks"."assigned_to" = 1[2026-10-23 07:50:15.894] local.info: [2.469ms] [rows:1] SELECT * FROM "users" WHERE "users"."id" = 1[2026-10-23 07:50:15.896] local.info: [0.738ms] [rows:3] SELECT * FROM "tasks" WHERE "tasks"."assigned_to" = 2[2026-10-23 07:50:15.896] local.info: [1.474ms] [rows:1] SELECT * FROM "users" WHERE "users"."id" = 2[2026-10-23 07:50:15.897] local.info: [0.528ms] [rows:1] SELECT * FROM "tasks" WHERE "tasks"."assigned_to" = 3[2026-10-23 07:50:15.897] local.info: [1.150ms] [rows:1] SELECT * FROM "users" WHERE "users"."id" = 3Seven queries for three users. This is called the N+1 problem. There is one query for the list, and then a separate load for each of the N users.
Here each user costs two queries, because Load reads the user again, together with their tasks. With 100 users, this would be 201 queries for one request.
Eager loading a list
Take the loop out and use With on the list, as in Relationships: Belongs To:
package routes
import ( "taskflow/app/facades" "taskflow/app/models"
"github.com/goravel/framework/contracts/http" "github.com/goravel/framework/contracts/route")
45 collapsed lines
type CreateTaskRequest struct { Title string `form:"title" json:"title"` Description string `form:"description" json:"description"`}
func Api() { facades.Route().Prefix("api/v1").Group(func(router route.Router) { router.Post("tasks", func(ctx http.Context) http.Response { var request CreateTaskRequest if err := ctx.Request().Bind(&request); err != nil { return ctx.Response().Json(400, map[string]any{ "error": err.Error(), }) }
return ctx.Response().Json(200, request) }).Name("tasks.create")
router.Get("tasks/{id}", func(ctx http.Context) http.Response { var task models.Task err := facades.Orm().Query(). With("Creator"). With("Assignee"). Find(&task, ctx.Request().RouteInt("id")) if err != nil { return ctx.Response().Json(500, map[string]any{ "error": err.Error(), }) }
return ctx.Response().Json(200, task) })
router.Get("tasks", func(ctx http.Context) http.Response { status := ctx.Request().Query("status") page := ctx.Request().QueryInt("page", 1) filters := ctx.Request().QueryMap("filters")
return ctx.Response().Json(200, map[string]any{ "status": status, "page": page, "filters": filters, }) })
router.Get("users", func(ctx http.Context) http.Response { var users []models.User err := facades.Orm().Query().Find(&users) err := facades.Orm().Query(). With("AssignedTasks"). Find(&users) if err != nil { return ctx.Response().Json(500, map[string]any{ "error": err.Error(), }) }
for i := range users { if err := facades.Orm().Query().Load(&users[i], "AssignedTasks"); err != nil { return ctx.Response().Json(500, map[string]any{ "error": err.Error(), }) } }
return ctx.Response().Json(200, users) })17 collapsed lines
router.Get("users/{id}", func(ctx http.Context) http.Response { var user models.User err := facades.Orm().Query(). With("CreatedTasks"). With("AssignedTasks"). Find(&user, ctx.Request().RouteInt("id")) if err != nil { return ctx.Response().Json(500, map[string]any{ "error": err.Error(), }) }
return ctx.Response().Json(200, user) }) })}Send List users again. Ana has no tasks, Ben has three and Cleo has one, the same as with the loop. The log has two lines:
[2026-10-23 07:50:29.602] local.info: [1.504ms] [rows:4] SELECT * FROM "tasks" WHERE "tasks"."assigned_to" IN (1,2,3)[2026-10-23 07:50:29.602] local.info: [7.835ms] [rows:3] SELECT * FROM "users"One query reads the users. The other reads the tasks of all of them at once, with assigned_to IN (1,2,3), and GORM hands each task to its user. It stays two queries however many users there are.
Filtered and nested loads
With can do more than load a whole relation. It can leave some rows out, and it can load relations of the loaded rows. A done task is no longer work to do, so leave done tasks out. Then load who made each task:
package routes
import ( "taskflow/app/facades" "taskflow/app/models"
"github.com/goravel/framework/contracts/database/orm" "github.com/goravel/framework/contracts/http" "github.com/goravel/framework/contracts/route")46 collapsed lines
type CreateTaskRequest struct { Title string `form:"title" json:"title"` Description string `form:"description" json:"description"`}
func Api() { facades.Route().Prefix("api/v1").Group(func(router route.Router) { router.Post("tasks", func(ctx http.Context) http.Response { var request CreateTaskRequest if err := ctx.Request().Bind(&request); err != nil { return ctx.Response().Json(400, map[string]any{ "error": err.Error(), }) }
return ctx.Response().Json(200, request) }).Name("tasks.create")
router.Get("tasks/{id}", func(ctx http.Context) http.Response { var task models.Task err := facades.Orm().Query(). With("Creator"). With("Assignee"). Find(&task, ctx.Request().RouteInt("id")) if err != nil { return ctx.Response().Json(500, map[string]any{ "error": err.Error(), }) }
return ctx.Response().Json(200, task) })
router.Get("tasks", func(ctx http.Context) http.Response { status := ctx.Request().Query("status") page := ctx.Request().QueryInt("page", 1) filters := ctx.Request().QueryMap("filters")
return ctx.Response().Json(200, map[string]any{ "status": status, "page": page, "filters": filters, }) })
router.Get("users", func(ctx http.Context) http.Response { var users []models.User err := facades.Orm().Query(). With("AssignedTasks"). With("AssignedTasks", func(query orm.Query) orm.Query { return query.Where("status <> ?", models.StatusDone) }). With("AssignedTasks.Creator"). Find(&users) if err != nil { return ctx.Response().Json(500, map[string]any{ "error": err.Error(), }) }
return ctx.Response().Json(200, users) })17 collapsed lines
router.Get("users/{id}", func(ctx http.Context) http.Response { var user models.User err := facades.Orm().Query(). With("CreatedTasks"). With("AssignedTasks"). Find(&user, ctx.Request().RouteInt("id")) if err != nil { return ctx.Response().Json(500, map[string]any{ "error": err.Error(), }) }
return ctx.Response().Json(200, user) }) })}- The function given to
Withfilters what it loads. Here it leaves out tasks whose status is done. AssignedTasks.Creatorloads each task’s creator too. The dot goes one relation deeper.
Send List users again. The done task, “Plan the next release”, is gone, and each task has its creator.
[ { "created_at": null, "updated_at": null, "id": 1, "name": "Ana", "email": "ana@example.com", "role": "manager", "created_tasks": null, "assigned_tasks": [] }, { "created_at": null, "updated_at": null, "id": 2, "name": "Ben", "email": "ben@example.com", "role": "member", "created_tasks": null, "assigned_tasks": [ { "created_at": null, "updated_at": null, "id": 1, "created_by": 1, "creator": { "created_at": null, "updated_at": null, "id": 1, "name": "Ana", "email": "ana@example.com", "role": "manager", "created_tasks": null, "assigned_tasks": null }, "assigned_to": 2, "assignee": null, "title": "Ship v1", "description": "The first release", "status": "pending", "due_at": "2026-11-02 09:00:00" },22 collapsed lines
{ "created_at": null, "updated_at": null, "id": 4, "created_by": 1, "creator": { "created_at": null, "updated_at": null, "id": 1, "name": "Ana", "email": "ana@example.com", "role": "manager", "created_tasks": null, "assigned_tasks": null }, "assigned_to": 2, "assignee": null, "title": "Fix the login bug", "description": "Users get logged out", "status": "in-progress", "due_at": null } ] },33 collapsed lines
{ "created_at": null, "updated_at": null, "id": 3, "name": "Cleo", "email": "cleo@example.com", "role": "member", "created_tasks": null, "assigned_tasks": [ { "created_at": null, "updated_at": null, "id": 5, "created_by": 1, "creator": { "created_at": null, "updated_at": null, "id": 1, "name": "Ana", "email": "ana@example.com", "role": "manager", "created_tasks": null, "assigned_tasks": null }, "assigned_to": 3, "assignee": null, "title": "Review the API", "description": "Check every endpoint", "status": "pending", "due_at": null } ] }]Three queries this time:
[2026-10-23 07:50:47.458] local.info: [0.760ms] [rows:1] SELECT * FROM "users" WHERE "users"."id" = 1[2026-10-23 07:50:47.458] local.info: [4.612ms] [rows:3] SELECT * FROM "tasks" WHERE "tasks"."assigned_to" IN (1,2,3) AND status <> 'done'[2026-10-23 07:50:47.458] local.info: [9.377ms] [rows:3] SELECT * FROM "users"The filter is part of the tasks query. All the tasks were made by Ana, so one more query, for user 1, loads every creator. With more creators it is still one query, using IN.
The code for this part is in the tag part-06.