DEV Community

Cover image for Goravel validation and form requests
Krishan
Krishan

Posted on Edited on Originally published at devchalk.com

Goravel validation and form requests

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
  • 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:

$ curl http://localhost:3000/api/v1/tasks/abc
Enter fullscreen mode Exit fullscreen mode

GET /api/v1/tasks/abc

{
  "errors": {
    "id": {
      "integer": "The id field must be an integer.",
      "min": "The id field must be at least 1."
    }
  }
}
Enter fullscreen mode Exit fullscreen mode
$ curl http://localhost:3000/api/v1/tasks/0
Enter fullscreen mode Exit fullscreen mode

GET /api/v1/tasks/0

{
  "errors": {
    "id": {
      "min": "The id field must be at least 1."
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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."}}}
Enter fullscreen mode Exit fullscreen mode

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,
    })
 }
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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."
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

An empty page:

$ curl "http://localhost:3000/api/v1/tasks?page="
Enter fullscreen mode Exit fullscreen mode

GET /api/v1/tasks?page=

{
  "errors": {
    "page": {
      "filled": "The page field must have a value."
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Neither field is required, so a request without them still passes:

$ curl http://localhost:3000/api/v1/tasks
Enter fullscreen mode Exit fullscreen mode

GET /api/v1/tasks

{
  "filters": {},
  "page": 1,
  "status": ""
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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",
    }
}
Enter fullscreen mode Exit fullscreen mode
  • 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 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
Enter fullscreen mode Exit fullscreen mode
  • 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
}
Enter fullscreen mode Exit fullscreen mode

An empty body fails both required rules:

$ curl -X POST http://localhost:3000/api/v1/tasks \
    -H "Content-Type: application/json" \
    -d '{}'
Enter fullscreen mode Exit fullscreen mode

POST /api/v1/tasks

{
  "errors": {
    "description": {
      "required": "The description field is required."
    },
    "title": {
      "required": "The title field is required."
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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}'
Enter fullscreen mode Exit fullscreen mode

POST /api/v1/tasks

{
  "errors": {
    "assigned_to": {
      "exists": "The assigned to does not exist."
    },
    "title": {
      "string": "The title field must be a string."
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"}'
Enter fullscreen mode Exit fullscreen mode

POST /api/v1/tasks

{
  "errors": {
    "description": {
      "required": "The description field is required."
    },
    "title": {
      "required": "The title field is required."
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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",
+   }
+}
Enter fullscreen mode Exit fullscreen mode

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."
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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",
+   }
+}
Enter fullscreen mode Exit fullscreen mode

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"}'
Enter fullscreen mode Exit fullscreen mode

POST /api/v1/tasks

{
  "title": "Ship v1",
  "description": "The first release",
  "assigned_to": null,
  "due_at": null
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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."
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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"}'
Enter fullscreen mode Exit fullscreen mode

POST /api/v1/tasks

{
  "errors": {
    "due_at": {
      "after": "The due date field must be a date after now."
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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{},
    }
}
Enter fullscreen mode Exit fullscreen mode

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()
 }
Enter fullscreen mode Exit fullscreen mode

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."
}
Enter fullscreen mode Exit fullscreen mode
  • 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 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
Enter fullscreen mode Exit fullscreen mode

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"}'
Enter fullscreen mode Exit fullscreen mode

POST /api/v1/tasks

{
  "title": "Ship v1",
  "description": "The first release",
  "assigned_to": null,
  "due_at": "2030-01-01 00:00:00"
}
Enter fullscreen mode Exit fullscreen mode
$ 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"}'
Enter fullscreen mode Exit fullscreen mode

POST /api/v1/tasks

{
  "errors": {
    "due_at": {
      "future_date": "The due date must be a date in the future."
    }
  }
}
Enter fullscreen mode Exit fullscreen mode
$ 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"}'
Enter fullscreen mode Exit fullscreen mode

POST /api/v1/tasks

{
  "errors": {
    "due_at": {
      "date": "The due date field must be a valid date."
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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."
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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)