DevChalk

GoravelPart 5 of 20

Relationships: Belongs To

Tasks tied to the users who made them and the users they are assigned to

By Krishan Kumar9 min read

TaskFlow has a users table. A task is made by one user and can be assigned to a user who does the work. In this part we create the tasks table with foreign keys to users, load a task together with both of its users, and make sure a user’s password never ends up in the JSON.

CodeStart from the Models & Migrations code
Terminal window
git clone https://github.com/krishankumar01/goravel-taskflow
cd goravel-taskflow
git checkout part-04
cp .env.example .env
go run . artisan key:generate
docker compose up -d
go run . artisan migrate

The model

Create the model:

Terminal window
go run . artisan make:model Task
SUCCESS Model created successfully

Then add the fields from the database design:

app/models/task.go
package models
import (
"github.com/goravel/framework/database/orm"
"github.com/goravel/framework/support/carbon"
)
type Status string
const (
StatusPending Status = "pending"
StatusInProgress Status = "in-progress"
StatusDone Status = "done"
)
type Task struct {
orm.Model
CreatedBy uint `gorm:"not null"`
Creator *User `gorm:"foreignKey:CreatedBy"`
AssignedTo *uint
Assignee *User `gorm:"foreignKey:AssignedTo"`
Title string `gorm:"size:255;not null"`
Description string `gorm:"type:text;not null"`
Status Status `gorm:"type:enum('pending','in-progress','done');default:'pending';not null"`
DueAt *carbon.DateTime
}

A task belongs to a user when the task row holds that user’s id. Ours belongs to two users, so it has two pairs of fields:

  • CreatedBy holds the id of the user who made the task, and Creator is that user.
  • AssignedTo holds the id of the user who has to do it, and Assignee is that user.
  • Creator and Assignee are pointers, so they stay nil until we load them.
  • AssignedTo and DueAt are pointers too, because a new task may have nobody and no due date yet. As Models & Migrations showed, a pointer field gives a nullable column.
  • DueAt is a carbon.DateTime, the same type orm.Model uses for its timestamps.
  • Status has its own type with three values, like Role on the user.

The foreignKey tag tells GORM which field holds the user’s id. Without it, GORM looks for a field named CreatorID (the relation name plus ID), finds none, and every load of the task fails with:

invalid field found for struct taskflow/app/models.Task's field Creator: define a valid foreign key for relations or implement the Valuer/Scanner interface

We use CreatedBy because the column in the database design is created_by.

The migration

make:migration -m only reads the models you register, as Models & Migrations showed. Add Task next to User:

bootstrap/app.go
22 collapsed lines
package bootstrap
import (
"github.com/goravel/framework/contracts/database/schema"
contractsfoundation "github.com/goravel/framework/contracts/foundation"
"github.com/goravel/framework/foundation"
"taskflow/app/facades"
"taskflow/app/models"
"taskflow/config"
"taskflow/routes"
)
func Boot() contractsfoundation.Application {
return foundation.Setup().
WithMigrations(Migrations).
WithRouting(func() {
routes.Api()
routes.Web()
}).
WithProviders(Providers).
WithConfig(config.Boot).
WithCallback(func() {
facades.Schema().Extend(schema.Extension{
Models: []any{models.User{}},
Models: []any{models.User{}, models.Task{}},
})
}).
Create()
}

Then generate the migration from it:

Terminal window
go run . artisan make:migration create_tasks_table -m Task
SUCCESS Created Migration: create_tasks_table
SUCCESS Migration registered successfully

This is the migration it wrote. As in Models & Migrations, yours has a different number:

database/migrations/20261023063944_create_tasks_table.go
16 collapsed lines
package migrations
import (
"github.com/goravel/framework/contracts/database/schema"
"taskflow/app/facades"
)
type M20261023063944CreateTasksTable struct{}
// Signature The unique signature for the migration.
func (r *M20261023063944CreateTasksTable) Signature() string {
return "20261023063944_create_tasks_table"
}
// Up Run the migrations.
func (r *M20261023063944CreateTasksTable) Up() error {
if !facades.Schema().HasTable("tasks") {
return facades.Schema().Create("tasks", func(table schema.Blueprint) {
table.TimestampTz("created_at").Nullable()
table.TimestampTz("updated_at").Nullable()
table.BigIncrements("id")
table.UnsignedBigInteger("created_by")
table.UnsignedBigInteger("assigned_to").Nullable()
table.String("title", 255)
table.Text("description")
table.Enum("status", []any{"pending", "in-progress", "done"}).Default("pending")
table.TimestampTz("due_at").Nullable()
})
9 collapsed lines
}
return nil
}
// Down Reverse the migrations.
func (r *M20261023063944CreateTasksTable) Down() error {
return facades.Schema().DropIfExists("tasks")
}

Every column is there, but there are no foreign keys. Creator and Assignee are not columns, so make:migration skips them. And despite its name, the foreignKey tag adds nothing to the table. It only tells GORM how to load the user. To stop a task from pointing at a user who does not exist, the table needs real foreign keys.

Add the two foreign keys yourself:

database/migrations/20261023063944_create_tasks_table.go
16 collapsed lines
package migrations
import (
"github.com/goravel/framework/contracts/database/schema"
"taskflow/app/facades"
)
type M20261023063944CreateTasksTable struct{}
// Signature The unique signature for the migration.
func (r *M20261023063944CreateTasksTable) Signature() string {
return "20261023063944_create_tasks_table"
}
// Up Run the migrations.
func (r *M20261023063944CreateTasksTable) Up() error {
if !facades.Schema().HasTable("tasks") {
return facades.Schema().Create("tasks", func(table schema.Blueprint) {
table.TimestampTz("created_at").Nullable()
table.TimestampTz("updated_at").Nullable()
table.BigIncrements("id")
table.UnsignedBigInteger("created_by")
table.UnsignedBigInteger("assigned_to").Nullable()
table.String("title", 255)
table.Text("description")
table.Enum("status", []any{"pending", "in-progress", "done"}).Default("pending")
table.TimestampTz("due_at").Nullable()
table.Foreign("created_by").References("id").On("users")
table.Foreign("assigned_to").References("id").On("users").NullOnDelete()
})
}
return nil
6 collapsed lines
}
// Down Reverse the migrations.
func (r *M20261023063944CreateTasksTable) Down() error {
return facades.Schema().DropIfExists("tasks")
}
  • created_by must be the id of a user. Postgres also refuses to delete a user who still has tasks they made. That is what Postgres does when no rule is given, so the created_by line has none. We could tell it to delete their tasks with them instead, but then deleting a user would quietly delete their work.
  • assigned_to must be the id of a user too, or empty. With NullOnDelete, when that user is deleted, the task stays and has nobody assigned.

Code The code for every step of this part is in the tag part-05.

Run the migration

Terminal window
go run . artisan migrate
INFO Running migration
INFO Running: 20261023063944_create_tasks_table
SUCCESS Migration success

Only the new migration runs, in batch 2:

Terminal window
go run . artisan migrate:status
Migration name .............................................. Batch / Status
20210101000002_create_jobs_table ................................... [1] Ran
20261022221906_create_users_table .................................. [1] Ran
20261023063944_create_tasks_table .................................. [2] Ran

db:table lists the foreign keys at the end:

Terminal window
go run . artisan db:table tasks
public.tasks ...............................................................
Columns .................................................................. 9
Size .............................................................. 0.016 MB
Column ................................................................ Type
created_at timestamptz, nullable ............... timestamp(0) with time zone
updated_at timestamptz, nullable ............... timestamp(0) with time zone
id autoincrement, int8 ............ nextval('tasks_id_seq'::regclass) bigint
created_by int8 ..................................................... bigint
assigned_to int8, nullable .......................................... bigint
title varchar ....................................... character varying(255)
description text ...................................................... text
status varchar ......... 'pending'::character varying character varying(255)
due_at timestamptz, nullable ................... timestamp(0) with time zone
Index ......................................................................
tasks_pkey id ....................................... btree, unique, primary
Foreign Key .......................................... On Update / On Delete
tasks_assigned_to_foreign assigned_to references id on users no action / set null
tasks_created_by_foreign created_by references id on users no action / no action

set null comes from NullOnDelete. no action is the default. created_by and assigned_to are bigint, not unsigned, because Postgres has no unsigned numbers.

Test data

TaskFlow has no endpoint that saves tasks yet, so add two users and two tasks with psql:

Terminal window
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');"
INSERT 0 2
docker compose exec postgres psql -U goravel -d taskflow \
-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);"
INSERT 0 2

Ana made both tasks. Ship v1 is assigned to Ben, and Write the docs has nobody yet. The password x is only a placeholder until we build register and login.

Now check that the foreign keys work. First, add a task made by user 99, who does not exist:

Terminal window
docker compose exec postgres psql -U goravel -d taskflow \
-c "INSERT INTO tasks (created_by, title, description) VALUES (99, 'Ghost', 'No such user');"
ERROR: insert or update on table "tasks" violates foreign key constraint "tasks_created_by_foreign"
DETAIL: Key (created_by)=(99) is not present in table "users".

Then try to delete Ana, who still has tasks:

Terminal window
docker compose exec postgres psql -U goravel -d taskflow \
-c "DELETE FROM users WHERE id = 1;"
ERROR: update or delete on table "users" violates foreign key constraint "tasks_created_by_foreign" on table "tasks"
DETAIL: Key (id)=(1) is still referenced from table "tasks".

Finding a row with Find

The show route from Reading Request Data only returns the id. Make it read the task from Postgres:

routes/api.go
package routes
import (
"taskflow/app/facades"
"taskflow/app/models"
"github.com/goravel/framework/contracts/http"
"github.com/goravel/framework/contracts/route"
)
18 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 {
id := ctx.Request().RouteInt("id")
var task models.Task
err := facades.Orm().Query().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, map[string]any{
"id": id,
})
return ctx.Response().Json(200, task)
})
14 collapsed lines
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,
})
})
})
}

Find reads the row with that id into task. If the query itself fails, the route answers 500 with the error. In requests.http, change the show request to task 1:

requests.http
8 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/5
GET http://localhost:3000/api/v1/tasks/1
### List tasks
GET http://localhost:3000/api/v1/tasks?status=done&page=2&filters[priority]=high
### Hello
GET http://localhost:3000/hello

If air is not running, start it, as in Routing & Response. Then send Show task from requests.http. Here is the response, formatted:

GET /api/v1/tasks/1
{
"created_at": null,
"updated_at": null,
"id": 1,
"CreatedBy": 1,
"Creator": null,
"AssignedTo": 2,
"Assignee": null,
"Title": "Ship v1",
"Description": "The first release",
"Status": "pending",
"DueAt": "2026-11-02 09:00:00"
}

The ids are there, but Creator and Assignee are null. Goravel never loads a related model unless you ask for it. Unlike Laravel, reading task.Creator later does not fetch the user. It stays nil.

The timestamps are null too. Goravel fills them when it saves a row, and these rows came from psql.

Ask for a task that does not exist, like /tasks/9, and Find returns no error. You get 200 OK and a task full of zeros. We turn that into a 404 when we build the task endpoints.

Eager loading with With

With tells the query to load a relation along with the task. This is called eager loading:

routes/api.go
28 collapsed lines
package routes
import (
"taskflow/app/facades"
"taskflow/app/models"
"github.com/goravel/framework/contracts/http"
"github.com/goravel/framework/contracts/route"
)
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().Find(&task, ctx.Request().RouteInt("id"))
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)
})
14 collapsed lines
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,
})
})
})
}

The name in With("Creator") is the field name, not the column name. GORM reads CreatedBy from the task and loads that user. Run the request again:

GET /api/v1/tasks/1
{
"created_at": null,
"updated_at": null,
"id": 1,
"CreatedBy": 1,
"Creator": {
"created_at": null,
"updated_at": null,
"id": 1,
"Name": "Ana",
"Email": "ana@example.com",
"Password": "x",
"Role": "manager"
},
"AssignedTo": 2,
"Assignee": {
"created_at": null,
"updated_at": null,
"id": 2,
"Name": "Ben",
"Email": "ben@example.com",
"Password": "x",
"Role": "member"
},
"Title": "Ship v1",
"Description": "The first release",
"Status": "pending",
"DueAt": "2026-11-02 09:00:00"
}

Both users are there, and so are their passwords. Here they are x, but once real password hashes are stored, anyone who asks for a task would get them.

The keys are mixed, too. id and created_at are lowercase, but Title and CreatedBy are not. orm.Model gives its fields JSON names, and our fields have none, so Go uses the field name as it is.

JSON field names

A json tag sets the name a field gets in the JSON, and json:"-" leaves the field out. Add them to the user:

app/models/user.go
14 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"`
Email string `gorm:"size:255;unique;not null"`
Password string `gorm:"size:255;not null"`
Role Role `gorm:"type:enum('manager','member');default:'member';not null"`
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"`
}

And to the task:

app/models/task.go
15 collapsed lines
package models
import (
"github.com/goravel/framework/database/orm"
"github.com/goravel/framework/support/carbon"
)
type Status string
const (
StatusPending Status = "pending"
StatusInProgress Status = "in-progress"
StatusDone Status = "done"
)
type Task struct {
orm.Model
CreatedBy uint `gorm:"not null"`
Creator *User `gorm:"foreignKey:CreatedBy"`
AssignedTo *uint
Assignee *User `gorm:"foreignKey:AssignedTo"`
Title string `gorm:"size:255;not null"`
Description string `gorm:"type:text;not null"`
Status Status `gorm:"type:enum('pending','in-progress','done');default:'pending';not null"`
DueAt *carbon.DateTime
CreatedBy uint `gorm:"not null" json:"created_by"`
Creator *User `gorm:"foreignKey:CreatedBy" json:"creator"`
AssignedTo *uint `json:"assigned_to"`
Assignee *User `gorm:"foreignKey:AssignedTo" json:"assignee"`
Title string `gorm:"size:255;not null" json:"title"`
Description string `gorm:"type:text;not null" json:"description"`
Status Status `gorm:"type:enum('pending','in-progress','done');default:'pending';not null" json:"status"`
DueAt *carbon.DateTime `json:"due_at"`
}

The gorm tags stay as they were, so the table does not change. Run the request again:

GET /api/v1/tasks/1
{
"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"
},
"assigned_to": 2,
"assignee": {
"created_at": null,
"updated_at": null,
"id": 2,
"name": "Ben",
"email": "ben@example.com",
"role": "member"
},
"title": "Ship v1",
"description": "The first release",
"status": "pending",
"due_at": "2026-11-02 09:00:00"
}

Every key is lowercase, and the passwords are gone.

The query log

APP_DEBUG is true in your .env, so Goravel writes every query it runs to a log file named after the day. Watch it in a second terminal, then send the request again:

Terminal window
tail -f storage/logs/goravel-$(date +%F).log
[2026-10-23 06:40:35.843] local.info: [1.498ms] [rows:1] SELECT * FROM "users" WHERE "users"."id" = 2
[2026-10-23 06:40:35.844] local.info: [0.726ms] [rows:1] SELECT * FROM "users" WHERE "users"."id" = 1
[2026-10-23 06:40:35.844] local.info: [7.740ms] [rows:1] SELECT * FROM "tasks" WHERE "tasks"."id" = 1

Three queries: one for the task, and one for each With. The task query runs first, but its line comes last, because Goravel writes a line when a query finishes and the task query waits for its users.

Ask for task 2, which has nobody assigned:

GET /api/v1/tasks/2
{
"created_at": null,
"updated_at": null,
"id": 2,
"created_by": 1,
"creator": {
"created_at": null,
"updated_at": null,
"id": 1,
"name": "Ana",
"email": "ana@example.com",
"role": "manager"
},
"assigned_to": null,
"assignee": null,
"title": "Write the docs",
"description": "Nobody has it yet",
"status": "pending",
"due_at": null
}

The log gets two new lines:

[2026-10-23 06:40:36.885] local.info: [0.475ms] [rows:1] SELECT * FROM "users" WHERE "users"."id" = 1
[2026-10-23 06:40:36.885] local.info: [2.427ms] [rows:1] SELECT * FROM "tasks" WHERE "tasks"."id" = 2

Two queries this time. With only runs a query when there is an id to look up.

Code

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

See the changesBrowse the codeDownload zip