Part 8 of the Goravel series. The original on DevChalk has the colour-matched code, the figures and the full diffs. Code for this part: krishankumar01/goravel-taskflow/tree/part-08.
The routes so far trust whatever the client sends. The create route accepts a body with no title, and /api/v1/tasks/abc reads the id as 0. In this part we check the input first, and answer 422 with a message for each field that is wrong.
Start from the Factories & Seeding code:
git clone https://github.com/krishankumar01/goravel-taskflow
cd goravel-taskflow
git checkout part-07
cp .env.example .env
go run . artisan key:generate
docker compose up -d
go run . artisan migrate:fresh --seed
go install github.com/air-verse/air@latest
air
Validating in the controller
In Reading Request Data, RouteInt read abc as 0 and Show carried on. Check the id before using it:
app/http/controllers/task_controller.go
// ... 35 lines not shown
func (r *TaskController) Show(ctx http.Context) http.Response {
+ validator, err := facades.Validation().Make(ctx, map[string]any{
+ "id": ctx.Request().Route("id"),
+ }, map[string]any{
+ "id": "integer|min:1",
+ })
+ if err != nil {
+ return ctx.Response().Json(500, map[string]any{
+ "error": err.Error(),
+ })
+ }
+ if validator.Fails() {
+ return ctx.Response().Json(422, map[string]any{
+ "errors": validator.Errors().All(),
+ })
+ }
+
var task models.Task
- err := facades.Orm().Query().
+ err = facades.Orm().Query().
With("Creator").
With("Assignee").
Find(&task, ctx.Request().RouteInt("id"))
// ... 20 lines not shown
-
facades.Validation().Makechecks the data you give it against a rule string for each field.integer|min:1is two rules, separated by|. Goravel has many more, listed in available validation rules. -
ctx.Request().Validatechecks the path, the query string and the body merged into one. A query key with the same name replaces the path value, so/tasks/abc?id=1would pass. SoShowpassesMakeonly theidfrom the path. - If the check itself cannot run, for example because a rule name is misspelled,
Makereturns an error, andShowanswers500. -
Failsis true when a rule did not pass.Showthen answers422withErrors().All().
Ask for task abc, then task 0:
$ curl http://localhost:3000/api/v1/tasks/abc
GET /api/v1/tasks/abc
{
"errors": {
"id": {
"integer": "The id field must be an integer.",
"min": "The id field must be at least 1."
}
}
}
$ curl http://localhost:3000/api/v1/tasks/0
GET /api/v1/tasks/0
{
"errors": {
"id": {
"min": "The id field must be at least 1."
}
}
}
Stopping at the first error
abc got two messages, one for each rule it broke. bail stops checking a field after its first failed rule:
app/http/controllers/task_controller.go
// ... 35 lines not shown
func (r *TaskController) Show(ctx http.Context) http.Response {
validator, err := facades.Validation().Make(ctx, map[string]any{
"id": ctx.Request().Route("id"),
}, map[string]any{
- "id": "integer|min:1",
+ "id": "bail|integer|min:1",
})
// ... 36 lines not shown
Now abc gets one message. Add -i to see the status too:
$ curl -i http://localhost:3000/api/v1/tasks/abc
HTTP/1.1 422 Unprocessable Entity
Content-Type: application/json; charset=utf-8
Date: Sat, 24 Oct 2026 08:51:15 GMT
Content-Length: 64
{"errors":{"id":{"integer":"The id field must be an integer."}}}
422 Unprocessable Entity means the request arrived and could be read, but the values in it are wrong.
Validating the query string
Index reads page with QueryInt, so ?page=abc turns into 0 the same way, and status takes any word. Its route has no path parameter, so ctx.Request().Validate is safe here:
app/http/controllers/task_controller.go
// ... 66 lines not shown
func (r *TaskController) Index(ctx http.Context) http.Response {
+ validator, err := ctx.Request().Validate(map[string]any{
+ "status": "in:pending,in-progress,done",
+ "page": "bail|filled|integer|min:1",
+ })
+ if err != nil {
+ return ctx.Response().Json(500, map[string]any{
+ "error": err.Error(),
+ })
+ }
+ 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,
})
}
in: lists the values that are allowed. filled rejects an empty page, which would otherwise skip the other rules and still read as 0. Send a status that is not in the list and a page that is not a number:
$ curl "http://localhost:3000/api/v1/tasks?status=archived&page=abc"
GET /api/v1/tasks?status=archived&page=abc
{
"errors": {
"page": {
"integer": "The page field must be an integer."
},
"status": {
"in": "The selected status is invalid."
}
}
}
An empty page:
$ curl "http://localhost:3000/api/v1/tasks?page="
GET /api/v1/tasks?page=
{
"errors": {
"page": {
"filled": "The page field must have a value."
}
}
}
Neither field is required, so a request without them still passes:
$ curl http://localhost:3000/api/v1/tasks
GET /api/v1/tasks
{
"filters": {},
"page": 1,
"status": ""
}
Form requests
The create route needs two more fields, the assignee and the due date, and each field needs its own rules. A form request keeps the fields and their rules together, outside the controller. Create one:
$ go run . artisan make:request StoreTaskRequest
SUCCESS Request created successfully
It creates app/http/requests/store_task_request.go with every method a form request can have, and a sample Name field. Only Authorize and Rules are required. Delete Attributes, Filters, Messages and PrepareForValidation, and the validation import that PrepareForValidation uses. We add the ones we need back later in this part. Fill in the file:
app/http/requests/store_task_request.go
package requests
import (
"github.com/goravel/framework/contracts/http"
"github.com/goravel/framework/support/carbon"
)
type StoreTaskRequest struct {
Title string `form:"title" json:"title"`
Description string `form:"description" json:"description"`
AssignedTo *uint `form:"assigned_to" json:"assigned_to"`
DueAt *carbon.DateTime `form:"due_at" json:"due_at"`
}
func (r *StoreTaskRequest) Authorize(ctx http.Context) error {
return nil
}
func (r *StoreTaskRequest) Rules(ctx http.Context) map[string]any {
return map[string]any{
"title": "required|string|max:255",
"description": "required|string",
"assigned_to": "bail|nullable|integer|exists:users,id|regex:^[0-9]+$",
"due_at": "nullable|date",
}
}
- The fields replace
CreateTaskRequestfrom Reading Request Data, with two more.AssignedToandDueAthave the same types as on theTaskmodel. -
exists:users,idchecks that the users table has a row with that id.bailstops atinteger, so a value that is not a number never reaches Postgres. -
integeralso accepts a number with spaces around it, like" 1", which the struct cannot hold.regex:^[0-9]+$turns those away. It goes last, because everything afterregex:in a rule string counts as its pattern. - A missing or empty field skips every rule except
requiredandfilled.nullablealso lets a client sendnullfor it. -
Authorizereturnsnil, so every request is allowed. We use it when we add permissions.
Use it in Store with ValidateRequest, and delete the old CreateTaskRequest struct from the controller:
app/http/controllers/task_controller.go
package controllers
import (
"taskflow/app/facades"
+ "taskflow/app/http/requests"
"taskflow/app/models"
"github.com/goravel/framework/contracts/http"
)
-
-type CreateTaskRequest struct {
- Title string `form:"title" json:"title"`
- Description string `form:"description" json:"description"`
-}
// ... 10 lines not shown
func (r *TaskController) Store(ctx http.Context) http.Response {
- var request CreateTaskRequest
- if err := ctx.Request().Bind(&request); err != nil {
- return ctx.Response().Json(400, map[string]any{
+ var request requests.StoreTaskRequest
+ errors, err := ctx.Request().ValidateRequest(&request)
+ if err != nil {
+ return ctx.Response().Json(500, map[string]any{
"error": err.Error(),
+ })
+ }
+ if errors != nil {
+ return ctx.Response().Json(422, map[string]any{
+ "errors": errors.All(),
})
}
return ctx.Response().Json(200, request)
}
// ... 57 lines not shown
-
ValidateRequestruns the rules and, if they all pass, fillsrequestby itsformtags. Thejsontags name the fields whenStoresendsrequestback. - It reads the same merged data as
Validate, so a field the body leaves out can come from the query string. -
errorsholds the rules that failed, and isnilwhen every rule passes. -
erris set when the check cannot run, as withMake, or whenAuthorizereturns an error.Storeanswers500for both.
Send Create task from requests.http. It passes, and the two new fields are null:
POST /api/v1/tasks
{
"title": "Ship v1",
"description": "The first release",
"assigned_to": null,
"due_at": null
}
An empty body fails both required rules:
$ curl -X POST http://localhost:3000/api/v1/tasks \
-H "Content-Type: application/json" \
-d '{}'
POST /api/v1/tasks
{
"errors": {
"description": {
"required": "The description field is required."
},
"title": {
"required": "The title field is required."
}
}
}
A title that is a number, and an assignee who does not exist:
$ curl -X POST http://localhost:3000/api/v1/tasks \
-H "Content-Type: application/json" \
-d '{"title":5,"description":"The first release","assigned_to":99}'
POST /api/v1/tasks
{
"errors": {
"assigned_to": {
"exists": "The assigned to does not exist."
},
"title": {
"string": "The title field must be a string."
}
}
}
exists looks the id up in Postgres, and the query log shows it:
[2026-10-23 17:42:02.266] local.info: [4.756ms] [rows:0] SELECT 1 FROM "users" WHERE "id" = 99 LIMIT 1
In Reading Request Data, a JSON body sent without the Content-Type header came back as 200 OK with an empty title. Now neither field is found, so both fail required:
$ curl -X POST http://localhost:3000/api/v1/tasks \
-d '{"title":"Ship v1","description":"The first release"}'
POST /api/v1/tasks
{
"errors": {
"description": {
"required": "The description field is required."
},
"title": {
"required": "The title field is required."
}
}
}
Readable field names
"The assigned to does not exist." reads badly. The messages use the field name with underscores turned into spaces. Attributes gives a field a better name:
app/http/requests/store_task_request.go
// ... 26 lines not shown
+
+func (r *StoreTaskRequest) Attributes(ctx http.Context) map[string]string {
+ return map[string]string{
+ "assigned_to": "assignee",
+ "due_at": "due date",
+ }
+}
Send the request with the number title and assignee 99 again:
POST /api/v1/tasks
{
"errors": {
"assigned_to": {
"exists": "The assignee does not exist."
},
"title": {
"string": "The title field must be a string."
}
}
}
Cleaning input with filters
A filter changes a value before the rules check it. Goravel has many filters, such as lower and to_int. trim removes spaces from both ends of the title and the description:
app/http/requests/store_task_request.go
// ... 33 lines not shown
+
+func (r *StoreTaskRequest) Filters(ctx http.Context) map[string]any {
+ return map[string]any{
+ "title": "trim",
+ "description": "trim",
+ }
+}
A title with spaces around it arrives without them:
$ curl -X POST http://localhost:3000/api/v1/tasks \
-H "Content-Type: application/json" \
-d '{"title":" Ship v1 ","description":"The first release"}'
POST /api/v1/tasks
{
"title": "Ship v1",
"description": "The first release",
"assigned_to": null,
"due_at": null
}
trim turns the value into a string first, so the title 5 from earlier now arrives as "5" and passes string. string can no longer fail, but we keep it so the rule still holds if the filter is removed.
Add a request with bad data to requests.http, so you can send it from the editor:
requests.http
# ... 9 lines not shown
+### Create task with bad data
+POST http://localhost:3000/api/v1/tasks
+Content-Type: application/json
+
+{
+ "title": " ",
+ "assigned_to": 99,
+ "due_at": "2020-01-01"
+}
+
### Show task
GET http://localhost:3000/api/v1/tasks/1
# ... 11 lines not shown
Send it:
POST /api/v1/tasks
{
"errors": {
"assigned_to": {
"exists": "The assignee does not exist."
},
"description": {
"required": "The description field is required."
},
"title": {
"required": "The title field is required."
}
}
}
required treats a title of only spaces as empty, with or without trim. The due date is in the past, and nothing checks that yet.
A custom rule
A due date should be in the future. Goravel has an after rule, but it takes a fixed date or the name of another field. It does not understand now, so after:now rejects every date. If you change the rule to "due_at": "nullable|date|after:now", even a date in 2030 fails:
$ curl -X POST http://localhost:3000/api/v1/tasks \
-H "Content-Type: application/json" \
-d '{"title":"Ship v1","description":"The first release","due_at":"2030-01-01"}'
POST /api/v1/tasks
{
"errors": {
"due_at": {
"after": "The due date field must be a date after now."
}
}
}
Put the rule back to nullable|date, and write a custom rule instead:
$ go run . artisan make:rule FutureDate
SUCCESS Rule created successfully
SUCCESS Rule registered successfully
Like make:seeder in Factories & Seeding, make:rule registers what it creates. It lists the rule in a new bootstrap/rules.go:
bootstrap/rules.go
package bootstrap
import (
"github.com/goravel/framework/contracts/validation"
"taskflow/app/rules"
)
func Rules() []validation.Rule {
return []validation.Rule{
&rules.FutureDate{},
}
}
It also added WithRules(Rules) to bootstrap/app.go:
bootstrap/app.go
package bootstrap
// ... 11 lines not shown
func Boot() contractsfoundation.Application {
return foundation.Setup().
+ WithRules(Rules).
WithSeeders(Seeders).
WithMigrations(Migrations).
// ... 11 lines not shown
Create()
}
make:rule already named the rule future_date in Signature. Fill in Passes and Message in app/rules/future_date.go:
app/rules/future_date.go
package rules
import (
"context"
"github.com/goravel/framework/contracts/validation"
"github.com/goravel/framework/support/carbon"
)
type FutureDate struct {
}
// Signature The name of the rule.
func (receiver *FutureDate) Signature() string {
return "future_date"
}
// Passes Determine if the validation rule passes.
func (receiver *FutureDate) Passes(ctx context.Context, data validation.Data, val any, options ...any) bool {
value, ok := val.(string)
if !ok {
return false
}
return carbon.Parse(value).Gt(carbon.Now())
}
// Message Get the validation error message.
func (receiver *FutureDate) Message(ctx context.Context) string {
return "The :attribute must be a date in the future."
}
-
Signaturereturns the name you use in a rule string. -
Passesgets the value and says whether it is allowed. A value that is not a string fails at once.carbon.Parsereads the date, andGtchecks that it is later than now. A date without a time means midnight, so today's date already counts as past. Send a time as well for a task due later today. - In
Message,:attributebecomes the field name, so the name fromAttributesis used.
Use it for the due date, with bail so a value that is not a date gets one message:
app/http/requests/store_task_request.go
// ... 18 lines not shown
func (r *StoreTaskRequest) Rules(ctx http.Context) map[string]any {
return map[string]any{
"title": "required|string|max:255",
"description": "required|string",
"assigned_to": "bail|nullable|integer|exists:users,id|regex:^[0-9]+$",
- "due_at": "nullable|date",
+ "due_at": "bail|nullable|date|future_date",
}
}
// ... 14 lines not shown
A date in 2030 passes, one in the past fails, and a word that is not a date stops at date:
$ curl -X POST http://localhost:3000/api/v1/tasks \
-H "Content-Type: application/json" \
-d '{"title":"Ship v1","description":"The first release","due_at":"2030-01-01"}'
POST /api/v1/tasks
{
"title": "Ship v1",
"description": "The first release",
"assigned_to": null,
"due_at": "2030-01-01 00:00:00"
}
$ curl -X POST http://localhost:3000/api/v1/tasks \
-H "Content-Type: application/json" \
-d '{"title":"Ship v1","description":"The first release","due_at":"2020-01-01"}'
POST /api/v1/tasks
{
"errors": {
"due_at": {
"future_date": "The due date must be a date in the future."
}
}
}
$ curl -X POST http://localhost:3000/api/v1/tasks \
-H "Content-Type: application/json" \
-d '{"title":"Ship v1","description":"The first release","due_at":"soon"}'
POST /api/v1/tasks
{
"errors": {
"due_at": {
"date": "The due date field must be a valid date."
}
}
}
Send Create task with bad data again. Now the due date fails too, and all four fields get a message:
POST /api/v1/tasks
{
"errors": {
"assigned_to": {
"exists": "The assignee does not exist."
},
"description": {
"required": "The description field is required."
},
"due_at": {
"future_date": "The due date must be a date in the future."
},
"title": {
"required": "The title field is required."
}
}
}
How ValidateRequest runs
Here is the order ValidateRequest works in, shown with three requests like the ones in this part:
Interactive figure on DevChalk: What ValidateRequest does with a request, in order.
ValidateRequest runs Authorize first; StoreTaskRequest returns nil, so the request goes on. It has no PrepareForValidation. Then the filters run, then the rules, then Bind, and then Store answers. With spaces in the title, trim turns " Ship v1 " into "Ship v1", every rule passes, Bind fills StoreTaskRequest using its form tags, and Store answers 200 OK. With assigned_to 99, exists asks Postgres for user 99, finds no row and fails. With due_at "soon", date fails and bail stops the field before future_date runs. When a rule fails, Bind copies nothing and Store answers 422 with errors.All().
New tasks are now checked field by field, with readable messages and a rule of our own. Anyone can still send them, without logging in.
Previous: 07. Factories & Seeding
Next: 09. Register & Login with JWT
Series: Goravel
Top comments (0)