DevChalk

Goravel series

Validation with Form Requests

Krishan Kumar · · Updated · 9 min See the changes ↗

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
Terminal window
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 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().Make checks the data you give it against a rule string for each field. integer|min:1 is two rules, separated by |. Goravel has many more, listed in available validation rules.
  • ctx.Request().Validate checks 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=1 would pass. So Show passes Make only the id from the path.
  • If the check itself cannot run, for example because a rule name is misspelled, Make returns an error, and Show answers 500.
  • Fails is true when a rule did not pass. Show then answers 422 with Errors().All().

Ask for task abc, then task 0:

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

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

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

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

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

Terminal window
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 CreateTaskRequest from Reading Request Data, with two more. AssignedTo and DueAt have the same types as on the Task model.
  • exists:users,id checks that the users table has a row with that id. bail stops at integer, so a value that is not a number never reaches Postgres.
  • integer also 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 after regex: in a rule string counts as its pattern.
  • A missing or empty field skips every rule except required and filled. nullable also lets a client send null for it.
  • Authorize returns nil, 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 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,
})
}
  • ValidateRequest runs the rules and, if they all pass, fills request by its form tags. The json tags name the fields when Store sends request back.
  • It reads the same merged data as Validate, so a field the body leaves out can come from the query string.
  • errors holds the rules that failed, and is nil when every rule passes.
  • err is set when the check cannot run, as with Make, or when Authorize returns an error. Store answers 500 for 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:

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

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

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

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

Terminal window
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 collapsed lines
### Create task
POST http://localhost:3000/api/v1/tasks
Content-Type: application/json
{
"title": "Ship v1",
"description": "The first release"
}
### 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 collapsed lines
### List tasks
GET http://localhost:3000/api/v1/tasks?status=done&page=2&filters[priority]=high
### List users
GET http://localhost:3000/api/v1/users
### Show user
GET http://localhost:3000/api/v1/users/2
### Hello
GET http://localhost:3000/hello

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:

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

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

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."
}
  • Signature returns the name you use in a rule string.
  • Passes gets the value and says whether it is allowed. A value that is not a string fails at once. carbon.Parse reads the date, and Gt checks 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, :attribute becomes the field name, so the name from Attributes is 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 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:

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

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