DevChalk

GoravelPart 6 of 20

Relationships: Has Many

Users loaded with the tasks they made and the tasks assigned to them

By Krishan Kumar9 min read

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
Terminal window
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 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"`
}
  • 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.

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:

Terminal window
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

Add a route that loads a user with both lists:

routes/api.go
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:

requests.http
9 collapsed lines
### Create task
POST http://localhost:3000/api/v1/tasks
Content-Type: application/json
{
"title": "Ship v1",
"description": "The first release"
}
### 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 & Response. 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"
},
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" = 2

Loading relations for a list

Add a route that lists every user. Leave out With for now, to see what an unloaded list looks like:

routes/api.go
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:

requests.http
9 collapsed lines
### Create task
POST http://localhost:3000/api/v1/tasks
Content-Type: application/json
{
"title": "Ship v1",
"description": "The first release"
}
### 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:

routes/api.go
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" = 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:

routes/api.go
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:

routes/api.go
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 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"
},
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.

Code

The code for this part is in the tag part-06.

See the changesBrowse the codeDownload zip