Part 11 of the Goravel series. The original on DevChalk has the colour-matched code, the figures and the full diffs. Code for this part: krishankumar01/goravel-taskflow/tree/part-11.

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

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

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

Start from the Auth Middleware & Rate Limits code:

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

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:

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

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

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

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_page is the page size, from 1 to 100, and 15 when it is missing. max:100 compares numbers, because the field also has integer.
  • The status filter is added only when status is sent.
  • Paginate counts the matching tasks into total, then reads one page of them.
  • The filters map from Reading Request Data goes. It was only there to show QueryMap.

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

requests.http

 # ... 60 lines not shown ### Show task GET http://localhost:3000/api/v1/tasks/1 Authorization: Bearer {{token}}   ### List tasks -GET http://localhost:3000/api/v1/tasks?status=done&page=2&filters[priority]=high +GET http://localhost:3000/api/v1/tasks?status=pending&per_page=5  Authorization: Bearer {{token}}   # ... 10 lines not shown 

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_by is the ID of the user the middleware loaded, so a client can't choose who made a task.
  • A new task starts as enums.StatusPending.
  • After Create, the task is read again into saved with its creator and assignee, and the answer is 201 Created.
  • errors is renamed validationErrors, because the next section imports Go's errors package.

Create a task assigned to user 2:

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

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 a 500.
  • The id check from Validation with Form Requests goes. RouteInt("abc") is 0, and no task has id 0, so abc is a 404 too.
  • WithContext(ctx) passes the request's context to the query, as in Register & Login with JWT.

users/{id} gets the same check:

app/http/controllers/user_controller.go

 package controllers   import ( + "errors" +  "taskflow/app/enums" "taskflow/app/facades" "taskflow/app/models"   "github.com/goravel/framework/contracts/database/orm" "github.com/goravel/framework/contracts/http" + frameworkerrors "github.com/goravel/framework/errors"  )   // ... 24 lines not shown func (r *UserController) Show(ctx http.Context) http.Response { var user models.User - err := facades.Orm().Query(). + err := facades.Orm().WithContext(ctx).Query().  With("CreatedTasks"). With("AssignedTasks"). - Find(&user, ctx.Request().RouteInt("id")) + FindOrFail(&user, ctx.Request().RouteInt("id")) + if errors.Is(err, frameworkerrors.OrmRecordNotFound) { + return ctx.Response().Json(404, map[string]any{ + "error": "The user does not exist.", + }) + }  if err != nil { facades.Log().Error(err) // ... 7 lines not shown 
$ 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.
  • ...