DEV Community

Cover image for Goravel REST API CRUD with filters and pagination
Krishan
Krishan

Posted on Edited on Originally published at devchalk.com

Goravel REST API CRUD with filters and pagination

Part 11 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-11.

In Auth Middleware & Rate Limits, the task routes started asking for a login, but they still only echo what they get:

  • Store saves nothing.
  • The list sends its own query string back.
  • There is no way to change or delete a task.

In this part the task routes read and write Postgres. The list gets a status filter and pages, and create, show, update and delete all work. First, the roles and statuses get their own package, and errors stop reaching the client.

Start from the Auth Middleware & Rate Limits code:

git clone https://github.com/krishankumar01/goravel-taskflow
cd goravel-taskflow
git checkout part-10
cp .env.example .env
go run . artisan key:generate
go run . artisan jwt:secret
docker compose up -d
go run . artisan migrate:fresh --seed
go install github.com/air-verse/air@latest
air
Enter fullscreen mode Exit fullscreen mode

Roles and statuses in their own package

Factories & Seeding wrote "member" and "pending" as plain strings. The factories can't import the models, because the models already import the factories, and Go doesn't allow two packages to import each other. So Role and Status move to app/enums, which imports neither:

app/enums/role.go

package enums

type Role string

const (
    RoleManager Role = "manager"
    RoleMember  Role = "member"
)
Enter fullscreen mode Exit fullscreen mode

app/enums/status.go

package enums

type Status string

const (
    StatusPending    Status = "pending"
    StatusInProgress Status = "in-progress"
    StatusDone       Status = "done"
)
Enter fullscreen mode Exit fullscreen mode

The models use the types from there:

app/models/task.go

 // ... 5 lines not shown
    "github.com/goravel/framework/support/carbon"

+   "taskflow/app/enums"
    "taskflow/database/factories"
-)
-
-type Status string
-
-const (
-   StatusPending    Status = "pending"
-   StatusInProgress Status = "in-progress"
-   StatusDone       Status = "done"
 )

 // ... 6 lines not shown
    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"`
+   Status      enums.Status     `gorm:"type:enum('pending','in-progress','done');default:'pending';not null" json:"status"`
    DueAt       *carbon.DateTime `json:"due_at"`
 }

 func (t *Task) Factory() factory.Factory {
    return &factories.TaskFactory{}
 }
Enter fullscreen mode Exit fullscreen mode

In the task factory, the plain strings become typed values, like StatusPending:

database/factories/task_factory.go

 package factories

 import (
    "github.com/brianvoe/gofakeit/v7"
    "github.com/goravel/framework/support/carbon"
+
+   "taskflow/app/enums"
 )

 // ... 13 lines not shown
        "Reply to support emails",
    })
-   status := gofakeit.RandomString([]string{"pending", "in-progress", "done"})
+   statuses := []enums.Status{enums.StatusPending, enums.StatusInProgress, enums.StatusDone}
+   status := statuses[gofakeit.Number(0, len(statuses)-1)]
    due := carbon.Now().AddDays(gofakeit.Number(1, 30)).StartOfDay().SetHour(9)

    // ... 7 lines not shown
Enter fullscreen mode Exit fullscreen mode

The user model, the user factory, both seeders and UserController change the same way:

The same change in five more files:

app/models/user.go

 package models

 import (
    "github.com/goravel/framework/contracts/database/factory"
    "github.com/goravel/framework/database/orm"

+   "taskflow/app/enums"
    "taskflow/database/factories"
-)
-
-type Role string
-
-const (
-   RoleManager Role = "manager"
-   RoleMember  Role = "member"
 )

 type User struct {
    orm.Model
-   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"`
+   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     enums.Role `gorm:"type:enum('manager','member');default:'member';not null" json:"role"`

    CreatedTasks  []Task `gorm:"foreignKey:CreatedBy" json:"created_tasks"`
    // ... 6 lines not shown
Enter fullscreen mode Exit fullscreen mode

database/factories/user_factory.go

 package factories

 import (
    "github.com/brianvoe/gofakeit/v7"

+   "taskflow/app/enums"
    "taskflow/app/facades"
 )
 // ... 10 lines not shown
        "Email":    gofakeit.Email(),
        "Password": password,
-       "Role":     "member",
+       "Role":     enums.RoleMember,
    }
 }
Enter fullscreen mode Exit fullscreen mode

database/seeders/user_seeder.go

 package seeders

 import (
+   "taskflow/app/enums"
    "taskflow/app/facades"
    "taskflow/app/models"
 // ... 14 lines not shown
        "Name":  "Ana",
        "Email": "ana@example.com",
-       "Role":  models.RoleManager,
+       "Role":  enums.RoleManager,
    })
    if err != nil {
        return err
    // ... 5 lines not shown
Enter fullscreen mode Exit fullscreen mode

database/seeders/task_seeder.go

 package seeders

 import (
+   "taskflow/app/enums"
    "taskflow/app/facades"
    "taskflow/app/models"
 // ... 11 lines not shown
 func (s *TaskSeeder) Run() error {
    var manager models.User
-   err := facades.Orm().Query().Where("role", models.RoleManager).FirstOrFail(&manager)
+   err := facades.Orm().Query().Where("role", enums.RoleManager).FirstOrFail(&manager)
    if err != nil {
        return err
    }

    var members []models.User
-   err = facades.Orm().Query().Where("role", models.RoleMember).Find(&members)
+   err = facades.Orm().Query().Where("role", enums.RoleMember).Find(&members)
    if err != nil {
        return err
    // ... 18 lines not shown
Enter fullscreen mode Exit fullscreen mode

app/http/controllers/user_controller.go

 package controllers

 import (
+   "taskflow/app/enums"
    "taskflow/app/facades"
    "taskflow/app/models"
 // ... 13 lines not shown
    err := facades.Orm().Query().
        With("AssignedTasks", func(query orm.Query) orm.Query {
-           return query.Where("status <> ?", models.StatusDone)
+           return query.Where("status <> ?", enums.StatusDone)
        }).
        With("AssignedTasks.Creator").
        // ... 24 lines not shown
Enter fullscreen mode Exit fullscreen mode

The tables don't change. Seed again to check the factories:

$ go run . artisan migrate:fresh --seed
 SUCCESS  Dropped all tables successfully
  INFO    Running migration
  INFO    Running: 20210101000002_create_jobs_table
  INFO    Running: 20261022221906_create_users_table
  INFO    Running: 20261023063944_create_tasks_table
 SUCCESS  Migration success
 SUCCESS  Database seeding completed successfully.
 SUCCESS  Migration fresh success
Enter fullscreen mode Exit fullscreen mode

The seed replaced the users, so an old token may belong to a user who no longer exists. Log in as Ana again:

$ curl -X POST http://localhost:3000/api/v1/login \
  -H "Content-Type: application/json" \
  -d '{"email":"ana@example.com","password":"password"}'
{"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9…"}

$ TOKEN=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9…
Enter fullscreen mode Exit fullscreen mode

Errors stay in the log

Relationships: Belongs To sent err.Error() to the client when a query failed, and said it would be replaced. A database error can name the database user, the database, the host and the port. The client only needs a plain message, and the real error goes to the log:

app/http/controllers/user_controller.go

 // ... 24 lines not shown
        Find(&users)
    if err != nil {
+       facades.Log().Error(err)
        return ctx.Response().Json(500, map[string]any{
-           "error": err.Error(),
+           "error": "Could not list the users.",
        })
    }
 // ... 9 lines not shown
        Find(&user, ctx.Request().RouteInt("id"))
    if err != nil {
+       facades.Log().Error(err)
        return ctx.Response().Json(500, map[string]any{
-           "error": err.Error(),
+           "error": "Could not load the user.",
        })
    }

    return ctx.Response().Json(200, user)
 }
Enter fullscreen mode Exit fullscreen mode

facades.Log().Error(err) writes the error to the day's log file, and the client gets "Could not list the users."

Every other 500 does the same. The ones in AuthController and the auth middleware already sent a plain message, but kept no record of the error:

The same line in front of every other 500:

app/http/controllers/task_controller.go

 // ... 22 lines not shown
    errors, err := ctx.Request().ValidateRequest(&request)
    if err != nil {
+       facades.Log().Error(err)
        return ctx.Response().Json(500, map[string]any{
-           "error": err.Error(),
+           "error": "Could not save the task.",
        })
    }
    // ... 13 lines not shown
        "id": "bail|integer|min:1",
    })
    if err != nil {
+       facades.Log().Error(err)
        return ctx.Response().Json(500, map[string]any{
-           "error": err.Error(),
+           "error": "Could not load the task.",
        })
    }
    // ... 10 lines not shown
        Find(&task, ctx.Request().RouteInt("id"))
    if err != nil {
+       facades.Log().Error(err)
        return ctx.Response().Json(500, map[string]any{
-           "error": err.Error(),
+           "error": "Could not load the task.",
        })
    }
 // ... 7 lines not shown
        "page":   "bail|filled|integer|min:1",
    })
    if err != nil {
+       facades.Log().Error(err)
        return ctx.Response().Json(500, map[string]any{
-           "error": err.Error(),
+           "error": "Could not list the tasks.",
        })
    }
    // ... 16 lines not shown
Enter fullscreen mode Exit fullscreen mode

app/http/controllers/auth_controller.go

 // ... 39 lines not shown
    validationErrors, err := ctx.Request().ValidateRequest(&request)
    if err != nil {
+       facades.Log().Error(err)
        return ctx.Response().Json(500, map[string]any{
            "error": "Could not create the account.",
        // ... 8 lines not shown
    password, err := facades.Hash().Make(request.Password)
    if err != nil {
+       facades.Log().Error(err)
        return ctx.Response().Json(500, map[string]any{
            "error": "Could not create the account.",
        // ... 6 lines not shown
        Password: password,
    }
    if err := facades.Orm().WithContext(ctx).Query().Create(&user); err != nil {
+       facades.Log().Error(err)
        return ctx.Response().Json(500, map[string]any{
            "error": "Could not create the account.",
        // ... 8 lines not shown
    validationErrors, err := ctx.Request().ValidateRequest(&request)
    if err != nil {
+       facades.Log().Error(err)
        return ctx.Response().Json(500, map[string]any{
            "error": "Could not log in.",
        // ... 8 lines not shown
    var user models.User
    if err := facades.Orm().WithContext(ctx).Query().Where("email", request.Email).First(&user); err != nil {
+       facades.Log().Error(err)
        return ctx.Response().Json(500, map[string]any{
            "error": "Could not log in.",
        // ... 13 lines not shown
    token, err := facades.Auth(ctx).Login(&user)
    if err != nil {
+       facades.Log().Error(err)
        return ctx.Response().Json(500, map[string]any{
            "error": "Could not log in.",
        // ... 23 lines not shown
            "error": err.Error(),
        })
    }
    if err != nil {
+       facades.Log().Error(err)
        return ctx.Response().Json(500, map[string]any{
            "error": "Could not refresh the token.",
        // ... 8 lines not shown
 func (r *AuthController) Logout(ctx http.Context) http.Response {
    if err := facades.Auth(ctx).Logout(); err != nil {
+       facades.Log().Error(err)
        return ctx.Response().Json(500, map[string]any{
            "error": "Could not log out.",
        // ... 5 lines not shown
Enter fullscreen mode Exit fullscreen mode

app/http/middleware/auth.go

 // ... 33 lines not shown
            return
        }

+       facades.Log().Error(err)
        ctx.Response().Json(500, map[string]any{
            "error": "Could not load the user.",
        // ... 7 lines not shown
Enter fullscreen mode Exit fullscreen mode

Try it with Postgres down:

  1. Stop Postgres.
  2. Stop air with Ctrl+C and start it again, so the app has no open connection.
  3. Ask for a user.

The auth middleware fails first, because it loads the signed-in user before any handler runs. Its answer is "Could not load the user."

$ docker compose stop postgres

$ curl http://localhost:3000/api/v1/users/2 -H "Authorization: Bearer $TOKEN"
{"error":"Could not load the user."}

$ docker compose start postgres
Enter fullscreen mode Exit fullscreen mode

The log has the reason:

[2026-10-24 20:51:40.517] local.error: failed to connect to `user=goravel database=taskflow`: 127.0.0.1:5433 (127.0.0.1): dial error: dial tcp 127.0.0.1:5433: connect: connection refused
Enter fullscreen mode Exit fullscreen mode

Listing tasks

The list from Validation with Form Requests checks status and page, then sends them back. Now it reads one page of tasks at a time, newest first, with Paginate:

app/http/controllers/task_controller.go

 // ... 71 lines not shown
 func (r *TaskController) Index(ctx http.Context) http.Response {
    validator, err := ctx.Request().Validate(map[string]any{
-       "status": "in:pending,in-progress,done",
-       "page":   "bail|filled|integer|min:1",
+       "status":   "in:pending,in-progress,done",
+       "page":     "bail|filled|integer|min:1",
+       "per_page": "bail|filled|integer|min:1|max:100",
    })
    if err != nil {
        facades.Log().Error(err)
        // ... 6 lines not shown
            "errors": validator.Errors().All(),
        })
    }

-   status := ctx.Request().Query("status")
    page := ctx.Request().QueryInt("page", 1)
-   filters := ctx.Request().QueryMap("filters")
+   perPage := ctx.Request().QueryInt("per_page", 15)
+
+   query := facades.Orm().WithContext(ctx).Query()
+   if status := ctx.Request().Query("status"); status != "" {
+       query = query.Where("status", status)
+   }
+
+   var tasks []models.Task
+   var total int64
+   err = query.With("Creator").With("Assignee").OrderByDesc("id").Paginate(page, perPage, &tasks, &total)
+   if err != nil {
+       facades.Log().Error(err)
+       return ctx.Response().Json(500, map[string]any{
+           "error": "Could not list the tasks.",
+       })
+   }

    return ctx.Response().Json(200, map[string]any{
-       "status":  status,
-       "page":    page,
-       "filters": filters,
+       "data": tasks,
+       "meta": map[string]any{
+           "page":     page,
+           "per_page": perPage,
+           "total":    total,
+       },
    })
 }
Enter fullscreen mode Exit fullscreen mode
  • per_page is the page size, from 1 to 100, and 15 when it is missing. max:100 compares numbers, because the field also has integer.
  • The status filter is added only when status is sent.
  • Paginate counts the matching tasks into total, then reads one page of them.
  • The filters map from Reading Request Data goes. It was only there to show QueryMap.

In requests.http, the list asks for pending tasks, five at a time:

requests.http

 # ... 60 lines not shown
 ### Show task
 GET http://localhost:3000/api/v1/tasks/1
 Authorization: Bearer {{token}}

 ### List tasks
-GET http://localhost:3000/api/v1/tasks?status=done&page=2&filters[priority]=high
+GET http://localhost:3000/api/v1/tasks?status=pending&per_page=5
 Authorization: Bearer {{token}}

 # ... 10 lines not shown
Enter fullscreen mode Exit fullscreen mode

Ask for the done tasks, one per page:

$ curl "http://localhost:3000/api/v1/tasks?status=done&per_page=1" -H "Authorization: Bearer $TOKEN"
Enter fullscreen mode Exit fullscreen mode

GET /api/v1/tasks?status=done&per_page=1

{
  "data": [
    {
      "created_at": "2026-10-24 20:51:32",
      "updated_at": "2026-10-24 20:51:32",
      "id": 6,
      "created_by": 1,
      "creator": {
        "created_at": "2026-10-24 20:51:31",
        "updated_at": "2026-10-24 20:51:31",
        "id": 1,
        "name": "Ana",
        "email": "ana@example.com",
        "role": "manager",
        "created_tasks": null,
        "assigned_tasks": null
      },
      "assigned_to": 4,
      "assignee": {
        "created_at": "2026-10-24 20:51:32",
        "updated_at": "2026-10-24 20:51:32",
        "id": 4,
        "name": "Julio Reese",
        "email": "raegangerlach@hudson.org",
        "role": "member",
        "created_tasks": null,
        "assigned_tasks": null
      },
      "title": "Fix the login bug",
      "description": "Backing up the bandwidth won't do anything, we need to back up the optical IB system!",
      "status": "done",
      "due_at": "2026-11-21 09:00:00"
    }
  ],
  "meta": {
    "page": 1,
    "per_page": 1,
    "total": 3
  }
}
Enter fullscreen mode Exit fullscreen mode

total counts every done task, and the next pages hold the rest. The query log has:

  • the count, with the same filter;
  • the page;
  • the creator and the assignee of the task on that page.
[2026-10-24 20:51:50.863] local.info: [3.681ms] [rows:1] SELECT count(*) FROM "tasks" WHERE "status" = 'done'
[2026-10-24 20:51:50.866] local.info: [0.728ms] [rows:1] SELECT * FROM "users" WHERE "users"."id" = 4
[2026-10-24 20:51:50.866] local.info: [0.653ms] [rows:1] SELECT * FROM "users" WHERE "users"."id" = 1
[2026-10-24 20:51:50.866] local.info: [3.030ms] [rows:1] SELECT * FROM "tasks" WHERE "status" = 'done' ORDER BY id DESC LIMIT 1
Enter fullscreen mode Exit fullscreen mode

Log lines in this part leave out the [Context] and [Trace] lines Goravel writes under some of them, and from here on the middleware's query for the signed-in user.

A page size out of range, or a status that doesn't exist, gets a 422:

$ curl "http://localhost:3000/api/v1/tasks?per_page=150&status=finished" \
  -H "Authorization: Bearer $TOKEN"
{"errors":{"per_page":{"max":"The per page field must not be greater than 100."},"status":{"in":"The selected status is invalid."}}}
Enter fullscreen mode Exit fullscreen mode

Creating a task

Store checks the body with StoreTaskRequest and sends it back. Now it creates the task, with the signed-in user as its creator:

app/http/controllers/task_controller.go

 package controllers

 import (
+   "taskflow/app/enums"
    "taskflow/app/facades"
+   "taskflow/app/http/authuser"
    "taskflow/app/http/requests"
    "taskflow/app/models"
 // ... 14 lines not shown
 func (r *TaskController) Store(ctx http.Context) http.Response {
    var request requests.StoreTaskRequest
-   errors, err := ctx.Request().ValidateRequest(&request)
+   validationErrors, err := ctx.Request().ValidateRequest(&request)
    if err != nil {
        facades.Log().Error(err)
        return ctx.Response().Json(500, map[string]any{
            "error": "Could not save the task.",
        })
    }
-   if errors != nil {
+   if validationErrors != nil {
        return ctx.Response().Json(422, map[string]any{
-           "errors": errors.All(),
+           "errors": validationErrors.All(),
        })
    }

-   return ctx.Response().Json(200, request)
+   task := models.Task{
+       CreatedBy:   authuser.From(ctx).ID,
+       AssignedTo:  request.AssignedTo,
+       Title:       request.Title,
+       Description: request.Description,
+       Status:      enums.StatusPending,
+       DueAt:       request.DueAt,
+   }
+   if err := facades.Orm().WithContext(ctx).Query().Create(&task); err != nil {
+       facades.Log().Error(err)
+       return ctx.Response().Json(500, map[string]any{
+           "error": "Could not save the task.",
+       })
+   }
+
+   var saved models.Task
+   err = facades.Orm().WithContext(ctx).Query().With("Creator").With("Assignee").Find(&saved, task.ID)
+   if err != nil {
+       facades.Log().Error(err)
+       return ctx.Response().Json(500, map[string]any{
+           "error": "Could not load the task.",
+       })
+   }
+
+   return ctx.Response().Json(201, saved)
 }

 // ... 78 lines not shown
Enter fullscreen mode Exit fullscreen mode
  • created_by is the ID of the user the middleware loaded, so a client can't choose who made a task.
  • A new task starts as enums.StatusPending.
  • After Create, the task is read again into saved with its creator and assignee, and the answer is 201 Created.
  • errors is renamed validationErrors, because the next section imports Go's errors package.

Create a task assigned to user 2:

$ curl -i -X POST http://localhost:3000/api/v1/tasks \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"title":"Ship v2","description":"The second release","assigned_to":2}'
HTTP/1.1 201 Created
Enter fullscreen mode Exit fullscreen mode

POST /api/v1/tasks

{
  "created_at": "2026-10-24 20:51:57",
  "updated_at": "2026-10-24 20:51:57",
  "id": 8,
  "created_by": 1,
  "creator": {
    "created_at": "2026-10-24 20:51:31",
    "updated_at": "2026-10-24 20:51:31",
    "id": 1,
    "name": "Ana",
    "email": "ana@example.com",
    "role": "manager",
    "created_tasks": null,
    "assigned_tasks": null
  },
  "assigned_to": 2,
  "assignee": {
    "created_at": "2026-10-24 20:51:32",
    "updated_at": "2026-10-24 20:51:32",
    "id": 2,
    "name": "Garrett Cook",
    "email": "giles.wagner@hawkins.com",
    "role": "member",
    "created_tasks": null,
    "assigned_tasks": null
  },
  "title": "Ship v2",
  "description": "The second release",
  "status": "pending",
  "due_at": null
}
Enter fullscreen mode Exit fullscreen mode

The log has the exists check for user 2, the insert with Ana's id, and the task read back with its two users:

[2026-10-24 20:51:57.074] local.info: [0.366ms] [rows:1] SELECT 1 FROM "users" WHERE "id" = 2 LIMIT 1
[2026-10-24 20:51:57.076] local.info: [1.592ms] [rows:1] INSERT INTO "tasks" ("created_at","updated_at","created_by","assigned_to","title","description","status","due_at") VALUES ('2026-10-24 20:51:57.074','2026-10-24 20:51:57.074',1,2,'Ship v2','The second release','pending',NULL) RETURNING "id"
[2026-10-24 20:51:57.077] local.info: [0.704ms] [rows:1] SELECT * FROM "users" WHERE "users"."id" = 2
[2026-10-24 20:51:57.078] local.info: [0.596ms] [rows:1] SELECT * FROM "users" WHERE "users"."id" = 1
[2026-10-24 20:51:57.078] local.info: [2.227ms] [rows:1] SELECT * FROM "tasks" WHERE "tasks"."id" = 8
Enter fullscreen mode Exit fullscreen mode

A task that does not exist

Show answers 200 with an empty task for an id that has no row, as Relationships: Belongs To showed. FindOrFail returns an error instead, and Show turns it into a 404:

app/http/controllers/task_controller.go

 package controllers

 import (
+   "errors"
+
    "taskflow/app/enums"
    "taskflow/app/facades"
    "taskflow/app/http/authuser"
    "taskflow/app/http/requests"
    "taskflow/app/models"

    "github.com/goravel/framework/contracts/http"
+   frameworkerrors "github.com/goravel/framework/errors"
 )

 // ... 51 lines not shown

 func (r *TaskController) Show(ctx http.Context) http.Response {
-   validator, err := facades.Validation().Make(ctx, map[string]any{
-       "id": ctx.Request().Route("id"),
-   }, map[string]any{
-       "id": "bail|integer|min:1",
-   })
-   if err != nil {
-       facades.Log().Error(err)
-       return ctx.Response().Json(500, map[string]any{
-           "error": "Could not load the task.",
+   var task models.Task
+   err := facades.Orm().WithContext(ctx).Query().
+       With("Creator").
+       With("Assignee").
+       FindOrFail(&task, ctx.Request().RouteInt("id"))
+   if errors.Is(err, frameworkerrors.OrmRecordNotFound) {
+       return ctx.Response().Json(404, map[string]any{
+           "error": "The task does not exist.",
        })
    }
-   if validator.Fails() {
-       return ctx.Response().Json(422, map[string]any{
-           "errors": validator.Errors().All(),
-       })
-   }
-
-   var task models.Task
-   err = facades.Orm().Query().
-       With("Creator").
-       With("Assignee").
-       Find(&task, ctx.Request().RouteInt("id"))
    if err != nil {
        facades.Log().Error(err)
        // ... 53 lines not shown
Enter fullscreen mode Exit fullscreen mode
  • A missing task gets "The task does not exist." The check, errors.Is(err, frameworkerrors.OrmRecordNotFound), is the one the auth middleware uses for a deleted user. Any other error is a 500.
  • The id check from Validation with Form Requests goes. RouteInt("abc") is 0, and no task has id 0, so abc is a 404 too.
  • WithContext(ctx) passes the request's context to the query, as in Register & Login with JWT.

users/{id} gets the same check:

app/http/controllers/user_controller.go

 package controllers

 import (
+   "errors"
+
    "taskflow/app/enums"
    "taskflow/app/facades"
    "taskflow/app/models"

    "github.com/goravel/framework/contracts/database/orm"
    "github.com/goravel/framework/contracts/http"
+   frameworkerrors "github.com/goravel/framework/errors"
 )

 // ... 24 lines not shown
 func (r *UserController) Show(ctx http.Context) http.Response {
    var user models.User
-   err := facades.Orm().Query().
+   err := facades.Orm().WithContext(ctx).Query().
        With("CreatedTasks").
        With("AssignedTasks").
-       Find(&user, ctx.Request().RouteInt("id"))
+       FindOrFail(&user, ctx.Request().RouteInt("id"))
+   if errors.Is(err, frameworkerrors.OrmRecordNotFound) {
+       return ctx.Response().Json(404, map[string]any{
+           "error": "The user does not exist.",
+       })
+   }
    if err != nil {
        facades.Log().Error(err)
        // ... 7 lines not shown
Enter fullscreen mode Exit fullscreen mode
$ curl http://localhost:3000/api/v1/tasks/99 -H "Authorization: Bearer $TOKEN"
{"error":"The task does not exist."}

$ curl http://localhost:3000/api/v1/tasks/abc -H "Authorization: Bearer $TOKEN"
{"error":"The task does not exist."}

$ curl http://localhost:3000/api/v1/users/99 -H "Authorization: Bearer $TOKEN"
{"error":"The user does not exist."}
Enter fullscreen mode Exit fullscreen mode

Updating a task

Routing & Responses listed PUT for Update. PUT replaces the whole task, and PATCH changes only the fields that are sent. Marking a task done sends only the status, so TaskFlow uses PATCH. Its rules go in a form request:

$ go run . artisan make:request UpdateTaskRequest
 SUCCESS  Request created successfully
Enter fullscreen mode Exit fullscreen mode

Fill it in, and delete Messages, PrepareForValidation, its validation import and the sample field:

app/http/requests/update_task_request.go

package requests

import (
    "github.com/goravel/framework/contracts/http"
)

type UpdateTaskRequest struct {
}

func (r *UpdateTaskRequest) Authorize(ctx http.Context) error {
    return nil
}

func (r *UpdateTaskRequest) Filters(ctx http.Context) map[string]any {
    return map[string]any{
        "title":       "trim",
        "description": "trim",
    }
}

func (r *UpdateTaskRequest) Rules(ctx http.Context) map[string]any {
    return map[string]any{
        "title":       "sometimes|required|string|max:255",
        "description": "sometimes|required|string",
        "assigned_to": "bail|sometimes|nullable|integer|exists:users,id|regex:^[0-9]+$",
        "due_at":      "bail|sometimes|nullable|date|future_date",
        "status":      "sometimes|required|in:pending,in-progress,done",
    }
}

func (r *UpdateTaskRequest) Attributes(ctx http.Context) map[string]string {
    return map[string]string{
        "assigned_to": "assignee",
        "due_at":      "due date",
    }
}
Enter fullscreen mode Exit fullscreen mode

UpdateTaskRequest has no fields. A struct can't tell an assignee that wasn't sent from one sent as null, so the handler reads what was sent from the validator instead.

  • Every rule starts with sometimes, so a field the body leaves out skips all its rules.
  • nullable lets a client send null for the assignee or the due date, to clear it.
  • The filters and field names match StoreTaskRequest.

The handler finds the task first, so a missing task is a 404 before any rule runs:

app/http/controllers/task_controller.go

 // ... 11 lines not shown
    "github.com/goravel/framework/contracts/http"
    frameworkerrors "github.com/goravel/framework/errors"
+   "github.com/goravel/framework/validation"
 )

 // ... 114 lines not shown
            "total":    total,
        },
    })
 }
+
+func (r *TaskController) Update(ctx http.Context) http.Response {
+   var task models.Task
+   err := facades.Orm().WithContext(ctx).Query().FindOrFail(&task, ctx.Request().RouteInt("id"))
+   if errors.Is(err, frameworkerrors.OrmRecordNotFound) {
+       return ctx.Response().Json(404, map[string]any{
+           "error": "The task does not exist.",
+       })
+   }
+   if err != nil {
+       facades.Log().Error(err)
+       return ctx.Response().Json(500, map[string]any{
+           "error": "Could not load the task.",
+       })
+   }
+
+   var request requests.UpdateTaskRequest
+   if err := request.Authorize(ctx); err != nil {
+       facades.Log().Error(err)
+       return ctx.Response().Json(500, map[string]any{
+           "error": "Could not save the task.",
+       })
+   }
+
+   validator, err := ctx.Request().Validate(
+       request.Rules(ctx),
+       validation.Filters(request.Filters(ctx)),
+       validation.Attributes(request.Attributes(ctx)),
+   )
+   if err != nil {
+       facades.Log().Error(err)
+       return ctx.Response().Json(500, map[string]any{
+           "error": "Could not save the task.",
+       })
+   }
+   if validator.Fails() {
+       return ctx.Response().Json(422, map[string]any{
+           "errors": validator.Errors().All(),
+       })
+   }
+
+   if changes := validator.Validated(); len(changes) > 0 {
+       if _, err := facades.Orm().WithContext(ctx).Query().Model(&task).Update(changes); err != nil {
+           facades.Log().Error(err)
+           return ctx.Response().Json(500, map[string]any{
+               "error": "Could not save the task.",
+           })
+       }
+   }
+
+   var saved models.Task
+   err = facades.Orm().WithContext(ctx).Query().With("Creator").With("Assignee").Find(&saved, task.ID)
+   if err != nil {
+       facades.Log().Error(err)
+       return ctx.Response().Json(500, map[string]any{
+           "error": "Could not load the task.",
+       })
+   }
+
+   return ctx.Response().Json(200, saved)
+}
Enter fullscreen mode Exit fullscreen mode

Update runs Authorize, then the request's rules, filters and field names with Validate. ValidateRequest does the same, then fills the struct, and returns only the errors. Update needs Validated(), so it calls Validate itself. If the request later gets Messages or PrepareForValidation, Update has to pass them in too.

Add the route:

routes/api.go

 // ... 26 lines not shown
            router.Get("tasks/{id}", taskController.Show)
            router.Get("tasks", taskController.Index)
+           router.Patch("tasks/{id}", taskController.Update)

            router.Get("users", userController.Index)
            router.Get("users/{id}", userController.Show)
        })
    })
 }
Enter fullscreen mode Exit fullscreen mode

Add a request for it to requests.http:

requests.http

 # ... 64 lines not shown
 ### List tasks
 GET http://localhost:3000/api/v1/tasks?status=pending&per_page=5
 Authorization: Bearer {{token}}

+### Update task
+PATCH http://localhost:3000/api/v1/tasks/1
+Authorization: Bearer {{token}}
+Content-Type: application/json
+
+{
+  "status": "in-progress"
+}
+
 ### List users
 GET http://localhost:3000/api/v1/users
 Authorization: Bearer {{token}}

 # ... 6 lines not shown
Enter fullscreen mode Exit fullscreen mode

Mark the new task as in progress:

$ curl -s -o /dev/null -w "%{http_code}\n" \
  -X PATCH http://localhost:3000/api/v1/tasks/8 \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"status":"in-progress"}'
200
Enter fullscreen mode Exit fullscreen mode

The UPDATE sets only the status:

[2026-10-24 20:52:07.441] local.info: [2.634ms] [rows:1] UPDATE "tasks" SET "status"='in-progress',"updated_at"='2026-10-24 20:52:07.438' WHERE "id" = 8
Enter fullscreen mode Exit fullscreen mode

Rename it with spaces around the title, and clear the assignee:

$ curl -s -o /dev/null -w "%{http_code}\n" \
  -X PATCH http://localhost:3000/api/v1/tasks/8 \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"title":"  Ship v2.0  ","assigned_to":null}'
200
Enter fullscreen mode Exit fullscreen mode
[2026-10-24 20:52:08.48] local.info: [0.875ms] [rows:1] UPDATE "tasks" SET "assigned_to"=NULL,"title"='Ship v2.0',"updated_at"='2026-10-24 20:52:08.479' WHERE "id" = 8
Enter fullscreen mode Exit fullscreen mode

The title was trimmed before it was saved, and null cleared the assignee.

Try to make user 3 the creator while marking the task done:

$ curl -s -o /dev/null -w "%{http_code}\n" \
  -X PATCH http://localhost:3000/api/v1/tasks/8 \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"status":"done","created_by":3}'
200
Enter fullscreen mode Exit fullscreen mode
[2026-10-24 20:52:09.512] local.info: [0.699ms] [rows:1] UPDATE "tasks" SET "status"='done',"updated_at"='2026-10-24 20:52:09.511' WHERE "id" = 8
Enter fullscreen mode Exit fullscreen mode

created_by has no rule, so only the status changed.

An empty body answers 200 with the task unchanged, and the log has no UPDATE:

$ curl -s -o /dev/null -w "%{http_code}\n" \
  -X PATCH http://localhost:3000/api/v1/tasks/8 \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{}'
200
Enter fullscreen mode Exit fullscreen mode
[2026-10-24 20:52:10.557] local.info: [0.441ms] [rows:1] SELECT * FROM "tasks" WHERE "tasks"."id" = 8
[2026-10-24 20:52:10.558] local.info: [0.309ms] [rows:1] SELECT * FROM "users" WHERE "users"."id" = 1
[2026-10-24 20:52:10.558] local.info: [0.762ms] [rows:1] SELECT * FROM "tasks" WHERE "tasks"."id" = 8
Enter fullscreen mode Exit fullscreen mode

Bad values get a 422, and a task that doesn't exist gets a 404:

$ curl -X PATCH http://localhost:3000/api/v1/tasks/8 \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"title":"   ","status":"finished","assigned_to":99}'
{"errors":{"assigned_to":{"exists":"The assignee does not exist."},"status":{"in":"The selected status is invalid."},"title":{"required":"The title field is required."}}}

$ curl -X PATCH http://localhost:3000/api/v1/tasks/99 \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"status":"done"}' 
{"error":"The task does not exist."}
Enter fullscreen mode Exit fullscreen mode

Deleting a task

Deleting a row that doesn't exist is not an error in Goravel. It just deletes nothing. So Destroy looks the task up first with the same FindOrFail check as Show:

app/http/controllers/task_controller.go

 // ... 193 lines not shown
    return ctx.Response().Json(200, saved)
 }
+
+func (r *TaskController) Destroy(ctx http.Context) http.Response {
+   var task models.Task
+   err := facades.Orm().WithContext(ctx).Query().FindOrFail(&task, ctx.Request().RouteInt("id"))
+   if errors.Is(err, frameworkerrors.OrmRecordNotFound) {
+       return ctx.Response().Json(404, map[string]any{
+           "error": "The task does not exist.",
+       })
+   }
+   if err != nil {
+       facades.Log().Error(err)
+       return ctx.Response().Json(500, map[string]any{
+           "error": "Could not load the task.",
+       })
+   }
+
+   if _, err := facades.Orm().WithContext(ctx).Query().Delete(&task); err != nil {
+       facades.Log().Error(err)
+       return ctx.Response().Json(500, map[string]any{
+           "error": "Could not delete the task.",
+       })
+   }
+
+   return ctx.Response().NoContent()
+}
Enter fullscreen mode Exit fullscreen mode

NoContent() answers 204, as Logout does. Add the route and a request:

routes/api.go

 // ... 27 lines not shown
            router.Get("tasks", taskController.Index)
            router.Patch("tasks/{id}", taskController.Update)
+           router.Delete("tasks/{id}", taskController.Destroy)

            router.Get("users", userController.Index)
            router.Get("users/{id}", userController.Show)
        })
    })
 }
Enter fullscreen mode Exit fullscreen mode

requests.http

 # ... 73 lines not shown
 {
   "status": "in-progress"
 }

+### Delete task
+DELETE http://localhost:3000/api/v1/tasks/1
+Authorization: Bearer {{token}}
+
 ### List users
 GET http://localhost:3000/api/v1/users
 Authorization: Bearer {{token}}

 # ... 6 lines not shown
Enter fullscreen mode Exit fullscreen mode

Delete the new task:

$ curl -i -X DELETE http://localhost:3000/api/v1/tasks/8 -H "Authorization: Bearer $TOKEN"
HTTP/1.1 204 No Content
Enter fullscreen mode Exit fullscreen mode
[2026-10-24 20:52:16.782] local.info: [1.450ms] [rows:1] SELECT * FROM "tasks" WHERE "tasks"."id" = 8
[2026-10-24 20:52:16.784] local.info: [1.675ms] [rows:1] DELETE FROM "tasks" WHERE "tasks"."id" = 8
Enter fullscreen mode Exit fullscreen mode

It is gone, so both a second delete and a read get a 404:

$ curl -X DELETE http://localhost:3000/api/v1/tasks/8 -H "Authorization: Bearer $TOKEN"
{"error":"The task does not exist."}

$ curl http://localhost:3000/api/v1/tasks/8 -H "Authorization: Bearer $TOKEN"
{"error":"The task does not exist."}
Enter fullscreen mode Exit fullscreen mode

Soft deletes:

TaskFlow removes the row. With soft deletes, a model embeds orm.SoftDeletes and the migration adds a deleted_at column. Delete then only fills in deleted_at, and queries skip those rows. WithTrashed includes them, Restore brings one back, and ForceDelete removes it for good.

TaskFlow's tasks can now be listed, created, read, changed and deleted, by anyone who is logged in. A member can still change or delete a manager's task.


Previous: 10. Auth Middleware & Rate Limits
Next: 12. Gates & Policies
Series: Goravel

Top comments (0)