DEV Community

Cover image for Goravel belongs to relationships and foreign keys
Krishan
Krishan

Posted on Edited on Originally published at devchalk.com

Goravel belongs to relationships and foreign keys

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
 }
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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()
 }
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
  • 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.

Run the migration

$ go run . artisan migrate
  INFO    Running migration
  INFO    Running: 20261023063944_create_tasks_table
 SUCCESS  Migration success
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
  • 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:

$ 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
Enter fullscreen mode Exit fullscreen mode

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".
Enter fullscreen mode Exit fullscreen mode

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".
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"
}
Enter fullscreen mode Exit fullscreen mode

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"`
 }
Enter fullscreen mode Exit fullscreen mode

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"`
 }
Enter fullscreen mode Exit fullscreen mode

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"
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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

Top comments (0)