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.
CodeStart from the Factories & Seeding code
git clone https://github.com/krishankumar01/goravel-taskflowcd goravel-taskflowgit checkout part-07cp .env.example .envgo run . artisan key:generatedocker compose up -dgo run . artisan migrate:fresh --seedgo install github.com/air-verse/air@latestairValidating in the controller
In Reading Request Data, RouteInt read abc as 0 and Show carried on. Check the id before using it:
35 collapsed lines
package controllers
import ( "taskflow/app/facades" "taskflow/app/models"
"github.com/goravel/framework/contracts/http")
type CreateTaskRequest struct { Title string `form:"title" json:"title"` Description string `form:"description" json:"description"`}
type TaskController struct { // Dependent services}
func NewTaskController() *TaskController { return &TaskController{ // Inject services }}
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{ "error": err.Error(), }) }
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": "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 collapsed lines
if err != nil { return ctx.Response().Json(500, map[string]any{ "error": err.Error(), }) }
return ctx.Response().Json(200, task)}
func (r *TaskController) Index(ctx http.Context) http.Response { 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, })}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{ "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{ "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:
35 collapsed lines
package controllers
import ( "taskflow/app/facades" "taskflow/app/models"
"github.com/goravel/framework/contracts/http")
type CreateTaskRequest struct { Title string `form:"title" json:"title"` Description string `form:"description" json:"description"`}
type TaskController struct { // Dependent services}
func NewTaskController() *TaskController { return &TaskController{ // Inject services }}
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{ "error": err.Error(), }) }
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": "integer|min:1", "id": "bail|integer|min:1", })36 collapsed lines
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(). With("Creator"). With("Assignee"). Find(&task, ctx.Request().RouteInt("id")) if err != nil { return ctx.Response().Json(500, map[string]any{ "error": err.Error(), }) }
return ctx.Response().Json(200, task)}
func (r *TaskController) Index(ctx http.Context) http.Response { 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, })}Now abc gets one message. Add -i to see the status too:
curl -i http://localhost:3000/api/v1/tasks/abcHTTP/1.1 422 Unprocessable EntityContent-Type: application/json; charset=utf-8Date: Sat, 24 Oct 2026 08:51:15 GMTContent-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:
66 collapsed lines
package controllers
import ( "taskflow/app/facades" "taskflow/app/models"
"github.com/goravel/framework/contracts/http")
type CreateTaskRequest struct { Title string `form:"title" json:"title"` Description string `form:"description" json:"description"`}
type TaskController struct { // Dependent services}
func NewTaskController() *TaskController { return &TaskController{ // Inject services }}
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{ "error": err.Error(), }) }
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 { 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(). With("Creator"). With("Assignee"). Find(&task, ctx.Request().RouteInt("id")) if err != nil { return ctx.Response().Json(500, map[string]any{ "error": err.Error(), }) }
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 { 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"{ "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="{ "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{ "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 successfullyIt 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:
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:
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 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 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 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 { 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(). With("Creator"). With("Assignee"). Find(&task, ctx.Request().RouteInt("id")) if err != nil { return ctx.Response().Json(500, map[string]any{ "error": err.Error(), }) }
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 { 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, })}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:
{ "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 '{}'{ "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}'{ "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 1In 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"}'{ "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:
26 collapsed lines
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", }}
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:
{ "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:
33 collapsed lines
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", }}
func (r *StoreTaskRequest) Attributes(ctx http.Context) map[string]string { return map[string]string{ "assigned_to": "assignee", "due_at": "due date", }}
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"}'{ "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:
9 collapsed lines
### Create taskPOST http://localhost:3000/api/v1/tasksContent-Type: application/json
{ "title": "Ship v1", "description": "The first release"}
### Create task with bad dataPOST http://localhost:3000/api/v1/tasksContent-Type: application/json
{ "title": " ", "assigned_to": 99, "due_at": "2020-01-01"}
### Show taskGET http://localhost:3000/api/v1/tasks/1
11 collapsed lines
### List tasksGET http://localhost:3000/api/v1/tasks?status=done&page=2&filters[priority]=high
### List usersGET http://localhost:3000/api/v1/users
### Show userGET http://localhost:3000/api/v1/users/2
### HelloGET http://localhost:3000/helloSend it:
{ "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"}'{ "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 successfullyLike make:seeder in Factories & Seeding, make:rule registers what it creates. It lists the rule in a new 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:
package bootstrap
11 collapsed lines
import ( "github.com/goravel/framework/contracts/database/schema" contractsfoundation "github.com/goravel/framework/contracts/foundation" "github.com/goravel/framework/foundation"
"taskflow/app/facades" "taskflow/app/models" "taskflow/config" "taskflow/routes")
func Boot() contractsfoundation.Application { return foundation.Setup(). WithRules(Rules). WithSeeders(Seeders). WithMigrations(Migrations).11 collapsed lines
WithRouting(func() { routes.Api() routes.Web() }). WithProviders(Providers). WithConfig(config.Boot). WithCallback(func() { facades.Schema().Extend(schema.Extension{ Models: []any{models.User{}, models.Task{}}, }) }). Create()}make:rule already named the rule future_date in Signature. Fill in Passes and Message in 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:
18 collapsed lines
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", "due_at": "bail|nullable|date|future_date", }}14 collapsed lines
func (r *StoreTaskRequest) Attributes(ctx http.Context) map[string]string { return map[string]string{ "assigned_to": "assignee", "due_at": "due date", }}
func (r *StoreTaskRequest) Filters(ctx http.Context) map[string]any { return map[string]any{ "title": "trim", "description": "trim", }}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"}'{ "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"}'{ "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"}'{ "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:
{ "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:
ValidateRequest does with a request, in order. Pick a request, then step through it.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.
git checkout part-08 See the changesBrowse the code ↗Download zip