In Auth Middleware, 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.
CodeStart from the Auth Middleware code
git clone https://github.com/krishankumar01/goravel-taskflowcd goravel-taskflowgit checkout part-10cp .env.example .envgo run . artisan key:generatego run . artisan jwt:secretdocker compose up -dgo run . artisan migrate:fresh --seedgo install github.com/air-verse/air@latestairRoles 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:
package enums
type Role string
const ( RoleManager Role = "manager" RoleMember Role = "member")package enums
type Status string
const ( StatusPending Status = "pending" StatusInProgress Status = "in-progress" StatusDone Status = "done")The models use the types from there:
5 collapsed lines
package models
import ( "github.com/goravel/framework/contracts/database/factory" "github.com/goravel/framework/database/orm" "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 collapsed lines
type Task struct { orm.Model 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"` 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:
package factories
import ( "github.com/brianvoe/gofakeit/v7" "github.com/goravel/framework/support/carbon"
"taskflow/app/enums")
13 collapsed lines
type TaskFactory struct {}
// Definition Define the model's default state.func (f *TaskFactory) Definition() map[string]any { title := gofakeit.RandomString([]string{ "Fix the login bug", "Write the API docs", "Review the pull request", "Plan the next release", "Update the dependencies", "Add tests for tasks", "Clean up old logs", "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 collapsed lines
return map[string]any{ "Title": title, "Description": gofakeit.HackerPhrase(), "Status": status, "DueAt": carbon.NewDateTime(due), }}The user model, the user factory, both seeders and UserController change the same way:
CodeThe same change in five more files
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 collapsed lines
AssignedTasks []Task `gorm:"foreignKey:AssignedTo" json:"assigned_tasks"`}
func (u *User) Factory() factory.Factory { return &factories.UserFactory{}}package factories
import ( "github.com/brianvoe/gofakeit/v7"
"taskflow/app/enums" "taskflow/app/facades")10 collapsed lines
type UserFactory struct {}
// Definition Define the model's default state.func (f *UserFactory) Definition() map[string]any { password, _ := facades.Hash().Make("password")
return map[string]any{ "Name": gofakeit.Name(), "Email": gofakeit.Email(), "Password": password, "Role": "member", "Role": enums.RoleMember, }}package seeders
import ( "taskflow/app/enums" "taskflow/app/facades" "taskflow/app/models"14 collapsed lines
)
type UserSeeder struct {}
// Signature The name and signature of the seeder.func (s *UserSeeder) Signature() string { return "UserSeeder"}
// Run executes the seeder logic.func (s *UserSeeder) Run() error { var manager models.User err := facades.Orm().Factory().Create(&manager, map[string]any{ "Name": "Ana", "Email": "ana@example.com", "Role": models.RoleManager, "Role": enums.RoleManager, }) if err != nil { return err5 collapsed lines
}
var members []models.User return facades.Orm().Factory().Count(3).Create(&members)}package seeders
import ( "taskflow/app/enums" "taskflow/app/facades" "taskflow/app/models"11 collapsed lines
)
type TaskSeeder struct {}
// Signature The name and signature of the seeder.func (s *TaskSeeder) Signature() string { return "TaskSeeder"}
// Run executes the seeder logic.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 err18 collapsed lines
}
for _, member := range members { var tasks []models.Task err = facades.Orm().Factory().Count(2).Create(&tasks, map[string]any{ "CreatedBy": manager.ID, "AssignedTo": &member.ID, }) if err != nil { return err } }
var unassigned models.Task return facades.Orm().Factory().Create(&unassigned, map[string]any{ "CreatedBy": manager.ID, })}package controllers
import ( "taskflow/app/enums" "taskflow/app/facades" "taskflow/app/models"13 collapsed lines
"github.com/goravel/framework/contracts/database/orm" "github.com/goravel/framework/contracts/http")
type UserController struct{}
func NewUserController() *UserController { return &UserController{}}
func (r *UserController) Index(ctx http.Context) http.Response { var users []models.User 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 collapsed lines
Find(&users) if err != nil { return ctx.Response().Json(500, map[string]any{ "error": err.Error(), }) }
return ctx.Response().Json(200, users)}
func (r *UserController) Show(ctx http.Context) http.Response { var user models.User err := facades.Orm().Query(). With("CreatedTasks"). With("AssignedTasks"). Find(&user, ctx.Request().RouteInt("id")) if err != nil { return ctx.Response().Json(500, map[string]any{ "error": err.Error(), }) }
return ctx.Response().Json(200, user)}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 successThe 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:
24 collapsed lines
package controllers
import ( "taskflow/app/enums" "taskflow/app/facades" "taskflow/app/models"
"github.com/goravel/framework/contracts/database/orm" "github.com/goravel/framework/contracts/http")
type UserController struct{}
func NewUserController() *UserController { return &UserController{}}
func (r *UserController) Index(ctx http.Context) http.Response { var users []models.User err := facades.Orm().Query(). With("AssignedTasks", func(query orm.Query) orm.Query { return query.Where("status <> ?", enums.StatusDone) }). With("AssignedTasks.Creator"). 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 collapsed lines
return ctx.Response().Json(200, users)}
func (r *UserController) Show(ctx http.Context) http.Response { var user models.User err := facades.Orm().Query(). With("CreatedTasks"). With("AssignedTasks"). 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:
CodeThe same line in front of every other 500
22 collapsed lines
package controllers
import ( "taskflow/app/facades" "taskflow/app/http/requests" "taskflow/app/models"
"github.com/goravel/framework/contracts/http")
type TaskController struct { // Dependent services}
func NewTaskController() *TaskController { return &TaskController{ // Inject services }}
func (r *TaskController) Store(ctx http.Context) http.Response { var request requests.StoreTaskRequest 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 collapsed lines
if errors != nil { return ctx.Response().Json(422, map[string]any{ "errors": errors.All(), }) }
return ctx.Response().Json(200, request)}
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": err.Error(), "error": "Could not load the task.", }) }10 collapsed lines
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) return ctx.Response().Json(500, map[string]any{ "error": err.Error(), "error": "Could not load the task.", }) }7 collapsed lines
return ctx.Response().Json(200, task)}
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", }) 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 collapsed lines
if validator.Fails() { return ctx.Response().Json(422, map[string]any{ "errors": validator.Errors().All(), }) }
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, })}39 collapsed lines
package controllers
import ( "errors" "sync"
"taskflow/app/facades" "taskflow/app/http/authuser" "taskflow/app/http/requests" "taskflow/app/models"
"github.com/goravel/framework/auth" "github.com/goravel/framework/contracts/http")
// noUserHash is a bcrypt hash of a password nobody has, made with the same cost as the// real ones. An unknown email is checked against it, so it takes as long to refuse as a// wrong password.var noUserHash = sync.OnceValue(func() string { hash, err := facades.Hash().Make("no user has this password") if err != nil { panic(err) }
return hash})
type AuthController struct { // Dependent services}
func NewAuthController() *AuthController { return &AuthController{ // Inject services }}
func (r *AuthController) Register(ctx http.Context) http.Response { var request requests.RegisterRequest 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 collapsed lines
}) } if validationErrors != nil { return ctx.Response().Json(422, map[string]any{ "errors": validationErrors.All(), }) }
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 collapsed lines
}) }
user := models.User{ Name: request.Name, Email: request.Email, 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 collapsed lines
}) }
return ctx.Response().Json(201, user)}
func (r *AuthController) Login(ctx http.Context) http.Response { var request requests.LoginRequest 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 collapsed lines
}) } if validationErrors != nil { return ctx.Response().Json(422, map[string]any{ "errors": validationErrors.All(), }) }
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 collapsed lines
}) }
hash := user.Password if user.ID == 0 { hash = noUserHash() } if !facades.Hash().Check(request.Password, hash) || user.ID == 0 { return ctx.Response().Json(401, map[string]any{ "error": "The email or password is wrong.", }) }
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 collapsed lines
}) }
return ctx.Response().Json(200, map[string]any{ "token": token, })}
func (r *AuthController) Me(ctx http.Context) http.Response { return ctx.Response().Json(200, authuser.From(ctx))}
func (r *AuthController) Refresh(ctx http.Context) http.Response { _, err := facades.Auth(ctx).Parse(ctx.Request().Header("Authorization")) if err != nil && !errors.Is(err, auth.ErrorTokenExpired) { return ctx.Response().Json(401, map[string]any{ "error": err.Error(), }) }
token, err := facades.Auth(ctx).Refresh() if errors.Is(err, auth.ErrorRefreshTimeExceeded) { return ctx.Response().Json(401, map[string]any{ "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 collapsed lines
}) }
return ctx.Response().Json(200, map[string]any{ "token": token, })}
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 collapsed lines
}) }
return ctx.Response().NoContent()}33 collapsed lines
package middleware
import ( "errors"
"taskflow/app/facades" "taskflow/app/http/authuser" "taskflow/app/models"
"github.com/goravel/framework/contracts/http" frameworkerrors "github.com/goravel/framework/errors")
type Auth struct{}
func (m *Auth) Signature() string { return "Auth"}
func (m *Auth) Handle(ctx http.Context) { if _, err := facades.Auth(ctx).Parse(ctx.Request().Header("Authorization")); err != nil { ctx.Response().Json(401, map[string]any{ "error": err.Error(), }).Abort() return }
var user models.User if err := facades.Auth(ctx).User(&user); err != nil { if errors.Is(err, frameworkerrors.OrmRecordNotFound) { ctx.Response().Json(401, map[string]any{ "error": "The user no longer exists.", }).Abort() return }
facades.Log().Error(err) ctx.Response().Json(500, map[string]any{ "error": "Could not load the user.",7 collapsed lines
}).Abort() return }
authuser.Set(ctx, &user) ctx.Request().Next()}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 postgresThe 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 refusedListing 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:
71 collapsed lines
package controllers
import ( "taskflow/app/facades" "taskflow/app/http/requests" "taskflow/app/models"
"github.com/goravel/framework/contracts/http")
type TaskController struct { // Dependent services}
func NewTaskController() *TaskController { return &TaskController{ // Inject services }}
func (r *TaskController) Store(ctx http.Context) http.Response { var request requests.StoreTaskRequest errors, 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 { return ctx.Response().Json(422, map[string]any{ "errors": errors.All(), }) }
return ctx.Response().Json(200, request)}
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.", }) } 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) return ctx.Response().Json(500, map[string]any{ "error": "Could not load the task.", }) }
return ctx.Response().Json(200, task)}
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 collapsed lines
return ctx.Response().Json(500, map[string]any{ "error": "Could not list the tasks.", }) } if validator.Fails() { return ctx.Response().Json(422, map[string]any{ "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:
60 collapsed lines
### RegisterPOST http://localhost:3000/api/v1/registerContent-Type: application/json
{ "name": "Ben", "email": "ben@example.com", "password": "secret123"}
### Log inPOST http://localhost:3000/api/v1/loginContent-Type: application/json
{ "email": "ben@example.com", "password": "secret123"}
> {% client.global.set("token", response.body.token);%}
### MeGET http://localhost:3000/api/v1/meAuthorization: Bearer {{token}}
### RefreshPOST http://localhost:3000/api/v1/refreshAuthorization: Bearer {{token}}
> {% client.global.set("token", response.body.token);%}
### Log outPOST http://localhost:3000/api/v1/logoutAuthorization: Bearer {{token}}
### Create taskPOST http://localhost:3000/api/v1/tasksAuthorization: Bearer {{token}}Content-Type: application/json
{ "title": "Ship v1", "description": "The first release"}
### Create task with bad dataPOST http://localhost:3000/api/v1/tasksAuthorization: Bearer {{token}}Content-Type: application/json
{ "title": " ", "assigned_to": 99, "due_at": "2020-01-01"}
### Show taskGET http://localhost:3000/api/v1/tasks/1Authorization: Bearer {{token}}
### List tasksGET http://localhost:3000/api/v1/tasks?status=done&page=2&filters[priority]=highGET http://localhost:3000/api/v1/tasks?status=pending&per_page=5Authorization: Bearer {{token}}
10 collapsed lines
### List usersGET http://localhost:3000/api/v1/usersAuthorization: Bearer {{token}}
### Show userGET http://localhost:3000/api/v1/users/2Authorization: Bearer {{token}}
### HelloGET http://localhost:3000/helloAsk for the done tasks, one per page:
curl "http://localhost:3000/api/v1/tasks?status=done&per_page=1" -H "Authorization: Bearer $TOKEN"{ "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 1Log 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:
package controllers
import ( "taskflow/app/enums" "taskflow/app/facades" "taskflow/app/http/authuser" "taskflow/app/http/requests" "taskflow/app/models"14 collapsed lines
"github.com/goravel/framework/contracts/http")
type TaskController struct { // Dependent services}
func NewTaskController() *TaskController { return &TaskController{ // Inject services }}
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 collapsed lines
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.", }) } 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) return ctx.Response().Json(500, map[string]any{ "error": "Could not load the task.", }) }
return ctx.Response().Json(200, task)}
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", "per_page": "bail|filled|integer|min:1|max:100", }) if err != nil { facades.Log().Error(err) return ctx.Response().Json(500, map[string]any{ "error": "Could not list the tasks.", }) } if validator.Fails() { return ctx.Response().Json(422, map[string]any{ "errors": validator.Errors().All(), }) }
page := ctx.Request().QueryInt("page", 1) 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{ "data": tasks, "meta": map[string]any{ "page": page, "per_page": perPage, "total": total, }, })}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{ "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" = 8A 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:
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 collapsed lines
type TaskController struct { // Dependent services}
func NewTaskController() *TaskController { return &TaskController{ // Inject services }}
func (r *TaskController) Store(ctx http.Context) http.Response { var request requests.StoreTaskRequest 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 validationErrors != nil { return ctx.Response().Json(422, map[string]any{ "errors": validationErrors.All(), }) }
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)}
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 collapsed lines
return ctx.Response().Json(500, map[string]any{ "error": "Could not load the task.", }) }
return ctx.Response().Json(200, task)}
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", "per_page": "bail|filled|integer|min:1|max:100", }) if err != nil { facades.Log().Error(err) return ctx.Response().Json(500, map[string]any{ "error": "Could not list the tasks.", }) } if validator.Fails() { return ctx.Response().Json(422, map[string]any{ "errors": validator.Errors().All(), }) }
page := ctx.Request().QueryInt("page", 1) 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{ "data": tasks, "meta": map[string]any{ "page": page, "per_page": perPage, "total": total, }, })}- 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:
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 collapsed lines
type UserController struct{}
func NewUserController() *UserController { return &UserController{}}
func (r *UserController) Index(ctx http.Context) http.Response { var users []models.User err := facades.Orm().Query(). With("AssignedTasks", func(query orm.Query) orm.Query { return query.Where("status <> ?", enums.StatusDone) }). With("AssignedTasks.Creator"). Find(&users) if err != nil { facades.Log().Error(err) return ctx.Response().Json(500, map[string]any{ "error": "Could not list the users.", }) }
return ctx.Response().Json(200, users)}
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 collapsed lines
return ctx.Response().Json(500, map[string]any{ "error": "Could not load the user.", }) }
return ctx.Response().Json(200, user)}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 & Response 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 successfullyFill it in, and delete Messages, PrepareForValidation, its validation import and the sample field:
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:
11 collapsed lines
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" "github.com/goravel/framework/validation")
114 collapsed lines
type TaskController struct { // Dependent services}
func NewTaskController() *TaskController { return &TaskController{ // Inject services }}
func (r *TaskController) Store(ctx http.Context) http.Response { var request requests.StoreTaskRequest 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 validationErrors != nil { return ctx.Response().Json(422, map[string]any{ "errors": validationErrors.All(), }) }
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)}
func (r *TaskController) Show(ctx http.Context) http.Response { 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 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, task)}
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", "per_page": "bail|filled|integer|min:1|max:100", }) if err != nil { facades.Log().Error(err) return ctx.Response().Json(500, map[string]any{ "error": "Could not list the tasks.", }) } if validator.Fails() { return ctx.Response().Json(422, map[string]any{ "errors": validator.Errors().All(), }) }
page := ctx.Request().QueryInt("page", 1) 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{ "data": tasks, "meta": map[string]any{ "page": page, "per_page": perPage, "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:
26 collapsed lines
package routes
import ( "taskflow/app/facades" "taskflow/app/http/controllers" "taskflow/app/http/middleware"
"github.com/goravel/framework/contracts/route" httpmiddleware "github.com/goravel/framework/http/middleware")
func Api() { authController := controllers.NewAuthController() taskController := controllers.NewTaskController() userController := controllers.NewUserController()
facades.Route().Prefix("api/v1").Group(func(router route.Router) { router.Post("register", authController.Register) router.Middleware(httpmiddleware.Throttle("login")).Post("login", authController.Login) router.Post("refresh", authController.Refresh)
router.Middleware(&middleware.Auth{}).Group(func(router route.Router) { router.Get("me", authController.Me) router.Post("logout", authController.Logout)
router.Post("tasks", taskController.Store).Name("tasks.create") 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:
64 collapsed lines
### RegisterPOST http://localhost:3000/api/v1/registerContent-Type: application/json
{ "name": "Ben", "email": "ben@example.com", "password": "secret123"}
### Log inPOST http://localhost:3000/api/v1/loginContent-Type: application/json
{ "email": "ben@example.com", "password": "secret123"}
> {% client.global.set("token", response.body.token);%}
### MeGET http://localhost:3000/api/v1/meAuthorization: Bearer {{token}}
### RefreshPOST http://localhost:3000/api/v1/refreshAuthorization: Bearer {{token}}
> {% client.global.set("token", response.body.token);%}
### Log outPOST http://localhost:3000/api/v1/logoutAuthorization: Bearer {{token}}
### Create taskPOST http://localhost:3000/api/v1/tasksAuthorization: Bearer {{token}}Content-Type: application/json
{ "title": "Ship v1", "description": "The first release"}
### Create task with bad dataPOST http://localhost:3000/api/v1/tasksAuthorization: Bearer {{token}}Content-Type: application/json
{ "title": " ", "assigned_to": 99, "due_at": "2020-01-01"}
### Show taskGET http://localhost:3000/api/v1/tasks/1Authorization: Bearer {{token}}
### List tasksGET http://localhost:3000/api/v1/tasks?status=pending&per_page=5Authorization: Bearer {{token}}
### Update taskPATCH http://localhost:3000/api/v1/tasks/1Authorization: Bearer {{token}}Content-Type: application/json
{ "status": "in-progress"}
### List usersGET http://localhost:3000/api/v1/usersAuthorization: Bearer {{token}}
6 collapsed lines
### Show userGET http://localhost:3000/api/v1/users/2Authorization: Bearer {{token}}
### HelloGET http://localhost:3000/helloMark 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"}'200The 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" = 8Rename 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" = 8The 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" = 8created_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" = 8Bad 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:
193 collapsed lines
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" "github.com/goravel/framework/validation")
type TaskController struct { // Dependent services}
func NewTaskController() *TaskController { return &TaskController{ // Inject services }}
func (r *TaskController) Store(ctx http.Context) http.Response { var request requests.StoreTaskRequest 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 validationErrors != nil { return ctx.Response().Json(422, map[string]any{ "errors": validationErrors.All(), }) }
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)}
func (r *TaskController) Show(ctx http.Context) http.Response { 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 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, task)}
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", "per_page": "bail|filled|integer|min:1|max:100", }) if err != nil { facades.Log().Error(err) return ctx.Response().Json(500, map[string]any{ "error": "Could not list the tasks.", }) } if validator.Fails() { return ctx.Response().Json(422, map[string]any{ "errors": validator.Errors().All(), }) }
page := ctx.Request().QueryInt("page", 1) 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{ "data": tasks, "meta": map[string]any{ "page": page, "per_page": perPage, "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)}
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:
27 collapsed lines
package routes
import ( "taskflow/app/facades" "taskflow/app/http/controllers" "taskflow/app/http/middleware"
"github.com/goravel/framework/contracts/route" httpmiddleware "github.com/goravel/framework/http/middleware")
func Api() { authController := controllers.NewAuthController() taskController := controllers.NewTaskController() userController := controllers.NewUserController()
facades.Route().Prefix("api/v1").Group(func(router route.Router) { router.Post("register", authController.Register) router.Middleware(httpmiddleware.Throttle("login")).Post("login", authController.Login) router.Post("refresh", authController.Refresh)
router.Middleware(&middleware.Auth{}).Group(func(router route.Router) { router.Get("me", authController.Me) router.Post("logout", authController.Logout)
router.Post("tasks", taskController.Store).Name("tasks.create") router.Get("tasks/{id}", taskController.Show) 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) }) })}73 collapsed lines
### RegisterPOST http://localhost:3000/api/v1/registerContent-Type: application/json
{ "name": "Ben", "email": "ben@example.com", "password": "secret123"}
### Log inPOST http://localhost:3000/api/v1/loginContent-Type: application/json
{ "email": "ben@example.com", "password": "secret123"}
> {% client.global.set("token", response.body.token);%}
### MeGET http://localhost:3000/api/v1/meAuthorization: Bearer {{token}}
### RefreshPOST http://localhost:3000/api/v1/refreshAuthorization: Bearer {{token}}
> {% client.global.set("token", response.body.token);%}
### Log outPOST http://localhost:3000/api/v1/logoutAuthorization: Bearer {{token}}
### Create taskPOST http://localhost:3000/api/v1/tasksAuthorization: Bearer {{token}}Content-Type: application/json
{ "title": "Ship v1", "description": "The first release"}
### Create task with bad dataPOST http://localhost:3000/api/v1/tasksAuthorization: Bearer {{token}}Content-Type: application/json
{ "title": " ", "assigned_to": 99, "due_at": "2020-01-01"}
### Show taskGET http://localhost:3000/api/v1/tasks/1Authorization: Bearer {{token}}
### List tasksGET http://localhost:3000/api/v1/tasks?status=pending&per_page=5Authorization: Bearer {{token}}
### Update taskPATCH http://localhost:3000/api/v1/tasks/1Authorization: Bearer {{token}}Content-Type: application/json
{ "status": "in-progress"}
### Delete taskDELETE http://localhost:3000/api/v1/tasks/1Authorization: Bearer {{token}}
### List usersGET http://localhost:3000/api/v1/usersAuthorization: Bearer {{token}}
6 collapsed lines
### Show userGET http://localhost:3000/api/v1/users/2Authorization: Bearer {{token}}
### HelloGET http://localhost:3000/helloDelete 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" = 8It 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."}MoreSoft 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.
git checkout part-11 See the changesBrowse the code ↗Download zip