DevChalk

Goravel series

Task CRUD

Krishan Kumar · · 11 min See the changes ↗

In Auth Middleware, 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.

CodeStart from the Auth Middleware code
Terminal window
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 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:

database/factories/task_factory.go
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
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 collapsed lines
AssignedTasks []Task `gorm:"foreignKey:AssignedTo" json:"assigned_tasks"`
}
func (u *User) Factory() factory.Factory {
return &factories.UserFactory{}
}
database/factories/user_factory.go
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,
}
}
database/seeders/user_seeder.go
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 err
5 collapsed lines
}
var members []models.User
return facades.Orm().Factory().Count(3).Create(&members)
}
database/seeders/task_seeder.go
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 err
18 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,
})
}
app/http/controllers/user_controller.go
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:

Terminal window
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:

Terminal window
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 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
app/http/controllers/task_controller.go
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,
})
}
app/http/controllers/auth_controller.go
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()
}
app/http/middleware/auth.go
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:

  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.”

Terminal window
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 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_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 collapsed lines
### Register
POST http://localhost:3000/api/v1/register
Content-Type: application/json
{
"name": "Ben",
"email": "ben@example.com",
"password": "secret123"
}
### Log in
POST http://localhost:3000/api/v1/login
Content-Type: application/json
{
"email": "ben@example.com",
"password": "secret123"
}
> {%
client.global.set("token", response.body.token);
%}
### Me
GET http://localhost:3000/api/v1/me
Authorization: Bearer {{token}}
### Refresh
POST http://localhost:3000/api/v1/refresh
Authorization: Bearer {{token}}
> {%
client.global.set("token", response.body.token);
%}
### Log out
POST http://localhost:3000/api/v1/logout
Authorization: Bearer {{token}}
### Create task
POST http://localhost:3000/api/v1/tasks
Authorization: Bearer {{token}}
Content-Type: application/json
{
"title": "Ship v1",
"description": "The first release"
}
### Create task with bad data
POST http://localhost:3000/api/v1/tasks
Authorization: Bearer {{token}}
Content-Type: application/json
{
"title": " ",
"assigned_to": 99,
"due_at": "2020-01-01"
}
### 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 collapsed lines
### List users
GET http://localhost:3000/api/v1/users
Authorization: Bearer {{token}}
### Show user
GET http://localhost:3000/api/v1/users/2
Authorization: Bearer {{token}}
### Hello
GET http://localhost:3000/hello

Ask for the done tasks, one per page:

Terminal window
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:

Terminal window
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 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_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:

Terminal window
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 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 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 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)
}
Terminal window
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:

Terminal window
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.
  • nullable lets a client send null for the assignee or the due date, to clear it.
  • The filters and field names match StoreTaskRequest.

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

app/http/controllers/task_controller.go
11 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.

Add the route:

routes/api.go
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:

requests.http
64 collapsed lines
### Register
POST http://localhost:3000/api/v1/register
Content-Type: application/json
{
"name": "Ben",
"email": "ben@example.com",
"password": "secret123"
}
### Log in
POST http://localhost:3000/api/v1/login
Content-Type: application/json
{
"email": "ben@example.com",
"password": "secret123"
}
> {%
client.global.set("token", response.body.token);
%}
### Me
GET http://localhost:3000/api/v1/me
Authorization: Bearer {{token}}
### Refresh
POST http://localhost:3000/api/v1/refresh
Authorization: Bearer {{token}}
> {%
client.global.set("token", response.body.token);
%}
### Log out
POST http://localhost:3000/api/v1/logout
Authorization: Bearer {{token}}
### Create task
POST http://localhost:3000/api/v1/tasks
Authorization: Bearer {{token}}
Content-Type: application/json
{
"title": "Ship v1",
"description": "The first release"
}
### Create task with bad data
POST http://localhost:3000/api/v1/tasks
Authorization: Bearer {{token}}
Content-Type: application/json
{
"title": " ",
"assigned_to": 99,
"due_at": "2020-01-01"
}
### Show task
GET http://localhost:3000/api/v1/tasks/1
Authorization: Bearer {{token}}
### 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 collapsed lines
### Show user
GET http://localhost:3000/api/v1/users/2
Authorization: Bearer {{token}}
### Hello
GET http://localhost:3000/hello

Mark the new task as in progress:

Terminal window
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:

Terminal window
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:

Terminal window
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:

Terminal window
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:

Terminal window
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 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:

routes/api.go
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)
})
})
}
requests.http
73 collapsed lines
### Register
POST http://localhost:3000/api/v1/register
Content-Type: application/json
{
"name": "Ben",
"email": "ben@example.com",
"password": "secret123"
}
### Log in
POST http://localhost:3000/api/v1/login
Content-Type: application/json
{
"email": "ben@example.com",
"password": "secret123"
}
> {%
client.global.set("token", response.body.token);
%}
### Me
GET http://localhost:3000/api/v1/me
Authorization: Bearer {{token}}
### Refresh
POST http://localhost:3000/api/v1/refresh
Authorization: Bearer {{token}}
> {%
client.global.set("token", response.body.token);
%}
### Log out
POST http://localhost:3000/api/v1/logout
Authorization: Bearer {{token}}
### Create task
POST http://localhost:3000/api/v1/tasks
Authorization: Bearer {{token}}
Content-Type: application/json
{
"title": "Ship v1",
"description": "The first release"
}
### Create task with bad data
POST http://localhost:3000/api/v1/tasks
Authorization: Bearer {{token}}
Content-Type: application/json
{
"title": " ",
"assigned_to": 99,
"due_at": "2020-01-01"
}
### Show task
GET http://localhost:3000/api/v1/tasks/1
Authorization: Bearer {{token}}
### 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"
}
### 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 collapsed lines
### Show user
GET http://localhost:3000/api/v1/users/2
Authorization: Bearer {{token}}
### Hello
GET http://localhost:3000/hello

Delete the new task:

Terminal window
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:

Terminal window
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.