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:
-
Storesaves 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
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"
)
app/enums/status.go
package enums
type Status string
const (
StatusPending Status = "pending"
StatusInProgress Status = "in-progress"
StatusDone Status = "done"
)
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{}
}
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
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
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,
}
}
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
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
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
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
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…
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)
}
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
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
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
Try it with Postgres down:
- Stop Postgres.
- Stop
airwith Ctrl+C and start it again, so the app has no open connection. - 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
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
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,
+ },
})
}
-
per_pageis the page size, from 1 to 100, and 15 when it is missing.max:100compares numbers, because the field also hasinteger. - The status filter is added only when
statusis sent. -
Paginatecounts the matching tasks intototal, then reads one page of them. - The
filtersmap from Reading Request Data goes. It was only there to showQueryMap.
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
Ask for the done tasks, one per page:
$ curl "http://localhost:3000/api/v1/tasks?status=done&per_page=1" -H "Authorization: Bearer $TOKEN"
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
}
}
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
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."}}}
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
-
created_byis theIDof 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 intosavedwith its creator and assignee, and the answer is201 Created. -
errorsis renamedvalidationErrors, because the next section imports Go'serrorspackage.
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
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
}
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
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
- 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 a500. - The id check from Validation with Form Requests goes.
RouteInt("abc")is0, and no task has id0, soabcis a404too. -
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
$ 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."}
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
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",
}
}
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. -
nullablelets a client sendnullfor 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)
+}
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.
-
Validated()holds the fields that were sent and passed, after the filters.Update(changes)writes only those columns, plusupdated_at. - Only fields with a rule reach
Validated(), so a client can't changecreated_byorid. - An empty body changes nothing.
- A field can also come from the query string, as with
ValidateRequestin Validation with Form Requests.
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)
})
})
}
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
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
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
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
[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
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
[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
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
[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
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."}
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()
+}
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)
})
})
}
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
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
[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
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."}
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)