Part 6 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-06.

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, and use the query log to find and fix the N+1 problem.

Start from the Relationships: Belongs To code:

git clone https://github.com/krishankumar01/goravel-taskflow cd goravel-taskflow git checkout part-05 cp .env.example .env go run . artisan key:generate docker compose up -d go run . artisan migrate docker 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:

app/models/user.go

 // ... 13 lines not shown 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"`  } 
  • CreatedTasks holds the tasks the user made. Its foreignKey:CreatedBy names the field on Task that holds this user's id.
  • AssignedTasks holds the tasks assigned to the user, linked by AssignedTo.
  • Without foreignKey, GORM looks for a UserID field on Task. There is none, so every query that reads users fails, even GET /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.

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 1 $ docker 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 3 

Three tasks are now assigned to Ben, and one to Cleo.

Loading a has many relation

The project's UserController is still the sample it came with. Add a Show method to it that loads a user with both lists:

app/http/controllers/user_controller.go

 package controllers   import ( + "taskflow/app/facades" + "taskflow/app/models" +  "github.com/goravel/framework/contracts/http" )  -type UserController struct {} +type UserController struct{}   func NewUserController() *UserController { return &UserController{} }   func (r *UserController) Index(ctx http.Context) http.Response { return ctx.Response().Success().Json(http.Json{ "Hello": "Goravel", }) } + +func (r *UserController) Show(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) +} 

Then its route:

routes/api.go

 // ... 9 lines not shown func Api() { taskController := controllers.NewTaskController() + userController := controllers.NewUserController()   facades.Route().Prefix("api/v1").Group(func(router route.Router) { router.Post("tasks", taskController.Store).Name("tasks.create") router.Get("tasks/{id}", taskController.Show) router.Get("tasks", taskController.Index) + + router.Get("users/{id}", userController.Show)  }) } 

Add a request for Ben, user 2:

requests.http

 # ... 9 lines not shown ### Show task GET http://localhost:3000/api/v1/tasks/1   ### List tasks GET http://localhost:3000/api/v1/tasks?status=done&page=2&filters[priority]=high  +### Show user +GET http://localhost:3000/api/v1/users/2 +  ### Hello GET http://localhost:3000/hello 

If air is not running, start it, as in Routing & Responses. Then send Show user. Here is the response, formatted:

GET /api/v1/users/2

{ "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" }, ... ] } 

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 Show 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" = 2 

Loading relations for a list

A list of users is where loading relations starts to cost queries. Replace the sample's Index with one that lists every user, and leave out With for now, to see what an unloaded list looks like:

app/http/controllers/user_controller.go

 // ... 15 lines not shown func (r *UserController) Index(ctx http.Context) http.Response { - return ctx.Response().Success().Json(http.Json{ - "Hello": "Goravel", - }) + 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)  }   // ... 14 lines not shown 

Add its route:

routes/api.go

 // ... 9 lines not shown func Api() { taskController := controllers.NewTaskController() userController := controllers.NewUserController()   facades.Route().Prefix("api/v1").Group(func(router route.Router) { router.Post("tasks", taskController.Store).Name("tasks.create") router.Get("tasks/{id}", taskController.Show) router.Get("tasks", taskController.Index)  + router.Get("users", userController.Index)  router.Get("users/{id}", userController.Show) }) } 

Index now answers /api/v1/users, so remove the sample's /users route from web.go:

routes/web.go

 package routes   import ( "github.com/goravel/framework/contracts/http" "github.com/goravel/framework/support"  - "taskflow/app/http/controllers"  "taskflow/app/facades" ) // ... 9 lines not shown  - userController := controllers.NewUserController() - facades.Route().Get("/users", userController.Index) -  facades.Route().Get("hello", func(ctx http.Context) http.Response { return ctx.Response().Json(200, facades.Route().Info("tasks.create")) }) } 

Add a request for it:

requests.http

 # ... 9 lines not shown ### Show task GET http://localhost:3000/api/v1/tasks/1   ### List tasks GET http://localhost:3000/api/v1/tasks?status=done&page=2&filters[priority]=high  +### List users +GET http://localhost:3000/api/v1/users +  ### Show user GET http://localhost:3000/api/v1/users/2   ### Hello GET http://localhost:3000/hello 

Send List users. Here is the response, formatted:

GET /api/v1/users

[ { "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:

app/http/controllers/user_controller.go

 // ... 15 lines not shown func (r *UserController) Index(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) }   // ... 14 lines not shown 

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" = 3 

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

app/http/controllers/user_controller.go

 // ... 15 lines not shown func (r *UserController) Index(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) }   // ... 14 lines not shown 

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:

app/http/controllers/user_controller.go

 package controllers   import ( "taskflow/app/facades" "taskflow/app/models"  + "github.com/goravel/framework/contracts/database/orm"  "github.com/goravel/framework/contracts/http" )   // ... 6 lines not shown func (r *UserController) Index(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) }   // ... 14 lines not shown 
  • The function given to With filters what it loads. Here it leaves out tasks whose status is done.
  • AssignedTasks.Creator loads 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.

GET /api/v1/users

[ { "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" }, ... ] }, ... ] 

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.

Users now load with their open tasks and who made each one, in a fixed number of queries. The test rows still have to be typed in by hand with psql.

Previous: 05. Relationships: Belongs To
Next: 07. Factories & Seeding
Series: Goravel