# Relationships: Belongs To

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

Goravel series, part 5 of 20. By Krishan Kumar. Published 2026-10-23.
Source: https://devchalk.com/goravel/05-relationships-belongs-to
Code: https://github.com/krishankumar01/goravel-taskflow/tree/part-05

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.

Start from the Models & Migrations code:

```bash
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:

```bash
$ go run . artisan make:model Task
 SUCCESS  Model created successfully
```

Then add the fields from the [database design](https://devchalk.com/goravel/01-installation-project-architecture#the-database-design):

```diff lang="go" title="app/models/task.go" t1:creator=/(CreatedBy) +uint/ t1:creator=/(Creator) +[*]User/ t2:assignee=/(AssignedTo) +[*]uint/ t2:assignee=/(Assignee) +[*]User/
 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](https://www.goravel.dev/orm/relationships#one-to-many-inverse-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](https://devchalk.com/goravel/04-models-migrations#the-model) 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](https://devchalk.com/goravel/01-installation-project-architecture#the-database-design) is `created_by`.

## The migration

`make:migration -m` only reads the models you register, as [Models & Migrations](https://devchalk.com/goravel/04-models-migrations#the-migration) showed. Add `Task` next to `User`:

```diff lang="go" title="bootstrap/app.go" collapse={1-22}
 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:

```bash
$ 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](https://devchalk.com/goravel/04-models-migrations#the-migration), yours has a different number:

```go title="database/migrations/20261023063944_create_tasks_table.go" collapse={1-16} collapse={30-38}
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()
		})
	}

	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:

```diff lang="go" title="database/migrations/20261023063944_create_tasks_table.go" collapse={1-16} collapse={36-44} t1:creator=/Foreign."(created_by)"/ t2:assignee=/Foreign."(assigned_to)"/
 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
 }
 
 // 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`](https://github.com/krishankumar01/goravel-taskflow/tree/part-05).

## Run the migration

```bash
$ go run . artisan migrate
  INFO    Running migration
  INFO    Running: 20261023063944_create_tasks_table
 SUCCESS  Migration success
```

Only the new migration runs, in batch 2:

```bash
$ 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:

```bash t1:creator="tasks_created_by_foreign" t2:assignee="tasks_assigned_to_foreign" t2:assignee="set null" t1:creator=/no action [/] (no action)/
$ 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`:

```bash
$ 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:

```bash
$ 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:

```bash
$ 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](https://devchalk.com/goravel/03-reading-request-data#route-parameters) only returns the id. Make it read the task from Postgres:

```diff lang="go" title="routes/api.go" collapse={11-28} collapse={44-57}
 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 {
-			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)
 		})
 
 		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:

```diff lang="http" title="requests.http" collapse={1-8}
 ### 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](https://devchalk.com/goravel/02-routing-response#hot-reload-with-air). Then send Show task from `requests.http`. Here is the response, formatted:

```json title="GET /api/v1/tasks/1" t1:creator=/"(Creator)":/ t2:assignee=/"(Assignee)":/
{
  "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](https://www.goravel.dev/orm/relationships#eager-loading):

```diff lang="go" title="routes/api.go" collapse={1-28} collapse={44-57} t1:creator=/With."(Creator)"/ t2:assignee=/With."(Assignee)"/
 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)
 		})
 
 		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:

```json title="GET /api/v1/tasks/1" t1:creator=/"(Creator)":/ t2:assignee=/"(Assignee)":/
{
  "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:

```diff lang="go" title="app/models/user.go" collapse={1-14}
 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:

```diff lang="go" title="app/models/task.go" collapse={1-15}
 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:

```json title="GET /api/v1/tasks/1" t1:creator=/"(creator)":/ t2:assignee=/"(assignee)":/
{
  "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:

```bash wrap t1:creator=/"users"."id" = (1)/ t2:assignee=/"users"."id" = (2)/
$ 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:

```json title="GET /api/v1/tasks/2" t1:creator=/"(creator)":/
{
  "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:

```text wrap
[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.

---

Previous: [04. Models & Migrations](https://devchalk.com/goravel/04-models-migrations.md)
Next: [06. Relationships: Has Many](https://devchalk.com/goravel/06-relationships-has-many.md)
Series: [Goravel](https://devchalk.com/goravel.md)
