# Validation with Form Requests

> Rejecting bad input before it reaches a handler.

Goravel series, part 8 of 20. By Krishan Kumar. Published 2026-10-24, Updated 2026-10-25.
Source: https://devchalk.com/goravel/08-validation-form-requests
Code: https://github.com/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:

```bash
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](https://devchalk.com/goravel/03-reading-request-data#route-parameters), `RouteInt` read `abc` as `0` and `Show` carried on. Check the id before using it:

```diff lang="go" title="app/http/controllers/task_controller.go" collapse={1-35} collapse={59-78}
 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"))
 	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`](https://www.goravel.dev/the-basics/validation#manually-creating-validators) 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](https://www.goravel.dev/the-basics/validation#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`:

```bash wrap
$ curl http://localhost:3000/api/v1/tasks/abc
```

```json title="GET /api/v1/tasks/abc"
{
  "errors": {
    "id": {
      "integer": "The id field must be an integer.",
      "min": "The id field must be at least 1."
    }
  }
}
```

```bash wrap
$ curl http://localhost:3000/api/v1/tasks/0
```

```json title="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:

```diff lang="go" title="app/http/controllers/task_controller.go" collapse={1-35} collapse={43-78}
 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",
 	})
 	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:

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

```diff lang="go" title="app/http/controllers/task_controller.go" collapse={1-66}
 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:

```bash wrap
$ curl "http://localhost:3000/api/v1/tasks?status=archived&page=abc"
```

```json title="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:

```bash wrap
$ curl "http://localhost:3000/api/v1/tasks?page="
```

```json title="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:

```bash wrap
$ curl http://localhost:3000/api/v1/tasks
```

```json title="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](https://www.goravel.dev/the-basics/validation#form-request-validation) keeps the fields and their rules together, outside the controller. Create one:

```bash
$ 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`](https://www.goravel.dev/the-basics/validation#customizing-the-error-messages) and [`PrepareForValidation`](https://www.goravel.dev/the-basics/validation#preparing-input-for-validation), and the `validation` import that `PrepareForValidation` uses. We add the ones we need back later in this part. Fill in the file:

```go title="app/http/requests/store_task_request.go" t3:form=/(form):"/
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](https://devchalk.com/goravel/03-reading-request-data#the-request-body), 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`](https://www.goravel.dev/the-basics/validation#authorizing-form-requests) 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:

```diff lang="go" title="app/http/controllers/task_controller.go" collapse={16-25} collapse={46-102}
 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"`
-}
 
 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)
 }
 
 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`:

```json title="POST /api/v1/tasks"
{
  "title": "Ship v1",
  "description": "The first release",
  "assigned_to": null,
  "due_at": null
}
```

An empty body fails both `required` rules:

```bash wrap
$ curl -X POST http://localhost:3000/api/v1/tasks \
    -H "Content-Type: application/json" \
    -d '{}'
```

```json title="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:

```bash wrap
$ curl -X POST http://localhost:3000/api/v1/tasks \
    -H "Content-Type: application/json" \
    -d '{"title":5,"description":"The first release","assigned_to":99}'
```

```json title="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](https://devchalk.com/goravel/05-relationships-belongs-to#the-query-log) shows it:

```text wrap
[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](https://devchalk.com/goravel/03-reading-request-data#the-request-body), 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`:

```bash wrap
$ curl -X POST http://localhost:3000/api/v1/tasks \
    -d '{"title":"Ship v1","description":"The first release"}'
```

```json title="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`](https://www.goravel.dev/the-basics/validation#customizing-the-validation-attributes) gives a field a better name:

```diff lang="go" title="app/http/requests/store_task_request.go" collapse={1-26} t2:assignee=/"(assignee)"/
 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:

```json title="POST /api/v1/tasks" t2:assignee=/The (assignee) does/
{
  "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](https://www.goravel.dev/the-basics/validation#available-validation-filters), such as `lower` and `to_int`. `trim` removes spaces from both ends of the title and the description:

```diff lang="go" title="app/http/requests/store_task_request.go" collapse={1-33}
 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:

```bash wrap
$ curl -X POST http://localhost:3000/api/v1/tasks \
    -H "Content-Type: application/json" \
    -d '{"title":"  Ship v1  ","description":"The first release"}'
```

```json title="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:

```diff lang="http" title="requests.http" collapse={1-9} collapse={23-33}
 ### 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
 
 ### 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:

```json title="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:

```bash wrap
$ 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"}'
```

```json title="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](https://www.goravel.dev/the-basics/validation#creating-custom-rules) instead:

```bash
$ go run . artisan make:rule FutureDate
 SUCCESS  Rule created successfully
 SUCCESS  Rule registered successfully
```

Like `make:seeder` in [Factories & Seeding](https://devchalk.com/goravel/07-factories-seeding#the-user-and-task-seeders), `make:rule` registers what it creates. It lists the rule in a new `bootstrap/rules.go`:

```go title="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`:

```diff lang="go" title="bootstrap/app.go" collapse={3-13} collapse={19-29}
 package bootstrap
 
 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).
 		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`:

```go title="app/rules/future_date.go" t1:future=/return "(future_date)"/
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:

```diff lang="go" title="app/http/requests/store_task_request.go" collapse={1-18} collapse={28-41} t1:future=/(future_date)",/
 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",
 	}
 }
 
 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`:

```bash wrap
$ 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"}'
```

```json title="POST /api/v1/tasks"
{
  "title": "Ship v1",
  "description": "The first release",
  "assigned_to": null,
  "due_at": "2030-01-01 00:00:00"
}
```

```bash wrap
$ 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"}'
```

```json title="POST /api/v1/tasks" t1:future=/"(future_date)":/
{
  "errors": {
    "due_at": {
      "future_date": "The due date must be a date in the future."
    }
  }
}
```

```bash wrap
$ 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"}'
```

```json title="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:

```json title="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.*

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](https://devchalk.com/goravel/07-factories-seeding.md)
Next: [09. Register & Login with JWT](https://devchalk.com/goravel/09-register-login-jwt.md)
Series: [Goravel](https://devchalk.com/goravel.md)
