Part 5 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-05.
TaskFlow has a users table. A task is made by one user and can be assigned to another. In this part we create the tasks table with foreign keys to users, load a task with both of its users, and keep each user's password out of the JSON.
Start from the Models & Migrations code:
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
A task needs its own table, and like users it starts as a model:
$ 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:
-
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 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 lines not shown 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 successfully This is the migration it wrote. As in Models & Migrations, yours has a different number:
database/migrations/20261023063944_create_tasks_table.go
// ... 16 lines not shown 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 lines not shown 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 lines not shown 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 } // ... 4 lines not shown -
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.
Run the migration
$ go run . artisan migrate INFO Running migration INFO Running: 20261023063944_create_tasks_table SUCCESS Migration success Only 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] Ran db: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 action -
set nullcomes fromNullOnDelete. -
no actionis the default. -
created_byandassigned_toarebigint, 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 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:
$ 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
Show, the handler from Reading Request Data, only returns the id. Make it read the task from Postgres:
app/http/controllers/task_controller.go
package controllers import ( + "taskflow/app/facades" + "taskflow/app/models" + "github.com/goravel/framework/contracts/http" ) // ... 26 lines not shown func (r *TaskController) Show(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) } // ... 11 lines not shown Find reads the row with that id into task. If the query itself fails, Show answers 500 with the error. Sending err.Error() to the client is fine while we build, but it can show table and column names. We replace it when we build the task endpoints.
In requests.http, change the show request to task 1:
requests.http
# ... 8 lines not shown ### 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 & Responses. 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:
app/http/controllers/task_controller.go
// ... 35 lines not shown func (r *TaskController) Show(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) } // ... 11 lines not shown 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 lines not shown 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 lines not shown 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:
$ 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.
A task now loads with both of its users, and no password leaves the API. A user can't list their own tasks yet.
Previous: 04. Models & Migrations
Next: 06. Relationships: Has Many
Series: Goravel
