# Task CRUD

> The task endpoints, with a status filter and pages.

Goravel series, part 11 of 20. By Krishan Kumar. Published 2026-10-25.
Source: https://devchalk.com/goravel/11-task-crud
Code: https://github.com/krishankumar01/goravel-taskflow/tree/part-11

In [Auth Middleware & Rate Limits](https://devchalk.com/goravel/10-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.

Start from the Auth Middleware & Rate Limits code:

```bash
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](https://devchalk.com/goravel/07-factories-seeding#the-user-factory) 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:

```go title="app/enums/role.go"
package enums

type Role string

const (
	RoleManager Role = "manager"
	RoleMember  Role = "member"
)
```

```go title="app/enums/status.go" t1:pending=/(StatusPending)/
package enums

type Status string

const (
	StatusPending    Status = "pending"
	StatusInProgress Status = "in-progress"
	StatusDone       Status = "done"
)
```

The models use the types from there:

```diff lang="go" title="app/models/task.go" collapse={1-5} collapse={20-25}
 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"
 )
 
 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`:

```diff lang="go" title="database/factories/task_factory.go" collapse={10-22} collapse={30-36} t1:pending=/("pending"|StatusPending)/
 package factories
 
 import (
 	"github.com/brianvoe/gofakeit/v7"
 	"github.com/goravel/framework/support/carbon"
+
+	"taskflow/app/enums"
 )
 
 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)
 
 	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:

The same change in five more files:

```diff lang="go" title="app/models/user.go" collapse={30-35}
 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"`
 	AssignedTasks []Task `gorm:"foreignKey:AssignedTo" json:"assigned_tasks"`
 }
 
 func (u *User) Factory() factory.Factory {
 	return &factories.UserFactory{}
 }
```

```diff lang="go" title="database/factories/user_factory.go" collapse={9-18}
 package factories
 
 import (
 	"github.com/brianvoe/gofakeit/v7"
 
+	"taskflow/app/enums"
 	"taskflow/app/facades"
 )
 
 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,
 	}
 }
```

```diff lang="go" title="database/seeders/user_seeder.go" collapse={7-20} collapse={28-32}
 package seeders
 
 import (
+	"taskflow/app/enums"
 	"taskflow/app/facades"
 	"taskflow/app/models"
 )
 
 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
 	}
 
 	var members []models.User
 	return facades.Orm().Factory().Count(3).Create(&members)
 }
```

```diff lang="go" title="database/seeders/task_seeder.go" collapse={7-17} collapse={31-48}
 package seeders
 
 import (
+	"taskflow/app/enums"
 	"taskflow/app/facades"
 	"taskflow/app/models"
 )
 
 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
 	}
 
 	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,
 	})
 }
```

```diff lang="go" title="app/http/controllers/user_controller.go" collapse={7-19} collapse={26-49}
 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 <> ?", models.StatusDone)
+			return query.Where("status <> ?", enums.StatusDone)
 		}).
 		With("AssignedTasks.Creator").
 		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:

```bash
$ 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:

```bash
$ 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](https://devchalk.com/goravel/05-relationships-belongs-to#finding-a-row-with-find) 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](https://www.goravel.dev/the-basics/logging#write-log-messages):

```diff lang="go" title="app/http/controllers/user_controller.go" collapse={1-24} collapse={33-41} t3:logged=/facades[.]Log[(][)][.](Error)/
 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.",
 		})
 	}
 
 	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:

The same line in front of every other 500:

```diff lang="go" title="app/http/controllers/task_controller.go" collapse={1-22} collapse={31-43} collapse={53-62} collapse={71-77} collapse={87-102}
 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.",
 		})
 	}
 	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.",
 		})
 	}
 	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.",
 		})
 	}
 
 	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.",
 		})
 	}
 	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,
 	})
 }
```

```diff lang="go" title="app/http/controllers/auth_controller.go" collapse={1-39} collapse={45-52} collapse={58-63} collapse={70-77} collapse={83-90} collapse={96-108} collapse={114-136} collapse={144-151} collapse={157-161}
 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.",
 		})
 	}
 	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.",
 		})
 	}
 
 	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.",
 		})
 	}
 
 	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.",
 		})
 	}
 	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.",
 		})
 	}
 
 	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.",
 		})
 	}
 
 	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.",
 		})
 	}
 
 	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.",
 		})
 	}
 
 	return ctx.Response().NoContent()
 }
```

```diff lang="go" title="app/http/middleware/auth.go" collapse={1-33} collapse={40-46}
 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.",
 		}).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."

```bash t2:msg=/"(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:

```text wrap t3:logged=/local[.](error)/
[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](https://devchalk.com/goravel/08-validation-form-requests#validating-the-query-string) checks `status` and `page`, then sends them back. Now it reads one page of tasks at a time, newest first, with [`Paginate`](https://www.goravel.dev/orm/getting-started#paginate):

```diff lang="go" title="app/http/controllers/task_controller.go" collapse={1-71} collapse={82-87} t1:perpage=/Paginate[(]page, (perPage)/ t2:total=/&(total)[)]/
 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)
 		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](https://devchalk.com/goravel/03-reading-request-data#query-strings) goes. It was only there to show `QueryMap`.

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

```diff lang="http" title="requests.http" collapse={1-60} collapse={70-79}
 ### 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}}
 
 ### 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:

```bash
$ curl "http://localhost:3000/api/v1/tasks?status=done&per_page=1" -H "Authorization: Bearer $TOKEN"
```

```json title="GET /api/v1/tasks?status=done&per_page=1" t1:perpage=/"per_page": (1)/ t2:total=/"total": ([0-9]+)/
{
  "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](https://devchalk.com/goravel/05-relationships-belongs-to#the-query-log) has:

- the count, with the same filter;
- the page;
- the creator and the assignee of the task on that page.

```text wrap t1:perpage=/LIMIT (1)$/ t2:total=/SELECT (count[(][*][)])/
[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`:

```bash
$ 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](https://www.goravel.dev/orm/getting-started#create) the task, with the signed-in user as its creator:

```diff lang="go" title="app/http/controllers/task_controller.go" collapse={9-22} collapse={69-146} t2:createdby=/authuser[.]From[(]ctx[)][.](ID)/ t1:created=/Json[(](201)/
 package controllers
 
 import (
+	"taskflow/app/enums"
 	"taskflow/app/facades"
+	"taskflow/app/http/authuser"
 	"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)
+	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)
 }
 
 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:

```bash t1:created=/HTTP[/]1[.]1 (201) Created/
$ 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
```

```json title="POST /api/v1/tasks" t2:createdby=/"created_by": (1)/
{
  "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:

```text wrap t2:createdby=/VALUES [(]'[^']+','[^']+',(1),/
[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](https://devchalk.com/goravel/05-relationships-belongs-to#finding-a-row-with-find) showed. [`FindOrFail`](https://www.goravel.dev/orm/getting-started#not-found-error) returns an error instead, and `Show` turns it into a `404`:

```diff lang="go" title="app/http/controllers/task_controller.go" collapse={16-66} collapse={101-153} t1:missing=/"(The task does not exist[.])"/
 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"
 )
 
 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)
 		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](https://devchalk.com/goravel/08-validation-form-requests#validating-in-the-controller) goes. `RouteInt("abc")` is `0`, and no task has id `0`, so `abc` is a `404` too.
- [`WithContext(ctx)`](https://www.goravel.dev/orm/getting-started#inject-context) passes the request's context to the query, as in [Register & Login with JWT](https://devchalk.com/goravel/09-register-login-jwt#registering).

`users/{id}` gets the same check:

```diff lang="go" title="app/http/controllers/user_controller.go" collapse={15-38} collapse={54-60}
 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"
 )
 
 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)
 		return ctx.Response().Json(500, map[string]any{
 			"error": "Could not load the user.",
 		})
 	}
 
 	return ctx.Response().Json(200, user)
 }
```

```bash t1:missing=/"(The task does not exist[.])"/
$ 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](https://devchalk.com/goravel/02-routing-response#controllers) listed `PUT` for `Update`. `PUT` replaces the whole task, and [`PATCH`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods/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:

```bash
$ go run . artisan make:request UpdateTaskRequest
 SUCCESS  Request created successfully
```

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

```go title="app/http/requests/update_task_request.go" t2:sometimes=/(sometimes)/
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:

```diff lang="go" title="app/http/controllers/task_controller.go" collapse={1-11} collapse={17-130} t3:validated=/validator[.](Validated)[(][)]/
 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)
+}
```

`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](https://www.goravel.dev/the-basics/validation#working-with-validated-input), after the filters. `Update(changes)` [writes only those columns](https://www.goravel.dev/orm/getting-started#update-columns), plus `updated_at`.
- Only fields with a rule reach `Validated()`, so a client can't change `created_by` or `id`.
- An empty body changes nothing.
- A field can also come from the query string, as with `ValidateRequest` in [Validation with Form Requests](https://devchalk.com/goravel/08-validation-form-requests#form-requests).

Add the route:

```diff lang="go" title="routes/api.go" collapse={1-26}
 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`:

```diff lang="http" title="requests.http" collapse={1-64} collapse={82-87}
 ### 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}}
 
 ### 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:

```bash
$ 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:

```text wrap t3:validated=/SET (.+?),"updated_at"/
[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:

```bash
$ 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
```

```text wrap t3:validated=/SET (.+?),"updated_at"/
[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:

```bash
$ 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
```

```text wrap t3:validated=/SET (.+?),"updated_at"/
[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`:

```bash
$ 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
```

```text wrap
[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`:

```bash t1:missing=/"(The task does not exist[.])"/
$ 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](https://www.goravel.dev/orm/getting-started#delete) 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`:

```diff lang="go" title="app/http/controllers/task_controller.go" collapse={1-193} t2:nocontent=/Response[(][)][.](NoContent)/
 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`](https://www.goravel.dev/the-basics/response#no-content), as `Logout` does. Add the route and a request:

```diff lang="go" title="routes/api.go" collapse={1-27}
 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)
 		})
 	})
 }
```

```diff lang="http" title="requests.http" collapse={1-73} collapse={86-91}
 ### 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}}
 
 ### Show user
 GET http://localhost:3000/api/v1/users/2
 Authorization: Bearer {{token}}
 
 ### Hello
 GET http://localhost:3000/hello
```

Delete the new task:

```bash t2:nocontent=/HTTP[/]1[.]1 (204 No Content)/
$ curl -i -X DELETE http://localhost:3000/api/v1/tasks/8 -H "Authorization: Bearer $TOKEN"
HTTP/1.1 204 No Content
```

```text wrap
[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`:

```bash t1:missing=/"(The task does not exist[.])"/
$ curl -X DELETE http://localhost:3000/api/v1/tasks/8 -H "Authorization: Bearer $TOKEN"
{"error":"The task does not exist."}

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

Soft deletes:

TaskFlow removes the row. With [soft deletes](https://www.goravel.dev/orm/getting-started#delete), a model embeds `orm.SoftDeletes` and the migration adds a `deleted_at` column. `Delete` then only fills in `deleted_at`, and queries skip those rows. `WithTrashed` includes them, `Restore` brings one back, and `ForceDelete` removes it for good.

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

---

Previous: [10. Auth Middleware & Rate Limits](https://devchalk.com/goravel/10-auth-middleware.md)
Next: [12. Gates & Policies](https://devchalk.com/goravel/12-gates-policies.md)
Series: [Goravel](https://devchalk.com/goravel.md)
