Relationships: Belongs To
Tasks tied to the users who made them and the users they are assigned to
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
git clone https://github.com/krishankumar01/goravel-taskflowcd goravel-taskflowgit checkout part-04cp .env.example .envgo run . artisan key:generatedocker compose up -dgo run . artisan migrateThe model
Create the model:
go run . artisan make:model Task SUCCESS Model created successfullyThen add the fields from the database design:
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:
CreatedByholds the id of the user who made the task, andCreatoris that user.AssignedToholds the id of the user who has to do it, andAssigneeis that user.CreatorandAssigneeare pointers, so they stayniluntil we load them.AssignedToandDueAtare 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.DueAtis acarbon.DateTime, the same typeorm.Modeluses for its timestamps.Statushas its own type with three values, likeRoleon 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 interfaceWe 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:
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:
go run . artisan make:migration create_tasks_table -m Task SUCCESS Created Migration: create_tasks_table SUCCESS Migration registered successfullyThis is the migration it wrote. As in Models & Migrations, yours has a different number:
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:
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 nil6 collapsed lines
}
// Down Reverse the migrations.func (r *M20261023063944CreateTasksTable) Down() error { return facades.Schema().DropIfExists("tasks")}created_bymust 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 thecreated_byline has none. We could tell it to delete their tasks with them instead, but then deleting a user would quietly delete their work.assigned_tomust be the id of a user too, or empty. WithNullOnDelete, 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
go run . artisan migrate INFO Running migration INFO Running: 20261023063944_create_tasks_table SUCCESS Migration successOnly the new migration runs, in batch 2:
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] Randb:table lists the foreign keys at the end:
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 actionset 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:
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 2docker 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 2Ana 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:
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:
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:
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:
8 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/5GET http://localhost:3000/api/v1/tasks/1
### List tasksGET http://localhost:3000/api/v1/tasks?status=done&page=2&filters[priority]=high
### HelloGET http://localhost:3000/helloIf air is not running, start it, as in Routing & Response. Then send Show task from requests.http. Here is the response, formatted:
{ "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:
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:
{ "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:
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:
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:
{ "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:
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" = 1Three 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:
{ "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" = 2Two queries this time. With only runs a query when there is an id to look up.
The code for this part is in the tag part-05.