Part 3 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-03.

Our routes answer, but they do not read anything the client sends. A client can send data in three places: the URL path, the query string and the request body. In this part we read all three.

Start from the Routing & Responses code:

git clone https://github.com/krishankumar01/goravel-taskflow cd goravel-taskflow git checkout part-02 cp .env.example .env go run . artisan key:generate docker compose up -d go install github.com/air-verse/air@latest air 

Route parameters

To get one task, the client puts its id in the path, like /api/v1/tasks/5. The id is different on every request, so the route needs a placeholder instead of a fixed number. Add a Show method to TaskController:

app/http/controllers/task_controller.go

 package controllers   import ( "github.com/goravel/framework/contracts/http" )   type TaskController struct { // Dependent services }   func NewTaskController() *TaskController { return &TaskController{ // Inject services } }   func (r *TaskController) Store(ctx http.Context) http.Response { return ctx.Response().String(200, "task created") } + +func (r *TaskController) Show(ctx http.Context) http.Response { + id := ctx.Request().RouteInt("id") + + return ctx.Response().Json(200, map[string]any{ + "id": id, + }) +} 

And its route in routes/api.go:

routes/api.go

 package routes   import ( "taskflow/app/facades" "taskflow/app/http/controllers"   "github.com/goravel/framework/contracts/route" )   func Api() { taskController := controllers.NewTaskController()   facades.Route().Prefix("api/v1").Group(func(router route.Router) { router.Post("tasks", taskController.Store).Name("tasks.create") + router.Get("tasks/{id}", taskController.Show)  }) } 

The {id} in the path is a route parameter. It matches any value in that part of the path. In the handler, RouteInt reads that value by the same name, "id", and returns it as a number. The two names have to match.

Two other methods read the same value as other types:

  • Route("id") returns it as a string.
  • RouteInt64("id") returns it as an int64.

Add a request for it:

requests.http

 ### Create task POST http://localhost:3000/api/v1/tasks  +### Show task +GET http://localhost:3000/api/v1/tasks/5 +  ### Hello GET http://localhost:3000/hello 

Run it, or use curl:

$ curl http://localhost:3000/api/v1/tasks/5 {"id":5} 

If the id is not a number, RouteInt does not return an error. It returns 0:

$ curl http://localhost:3000/api/v1/tasks/abc {"id":0} 

This API should reject that. We add that check later, with validation.

Query strings

To list tasks, the client may want only some of them, or one page at a time. Options like these go in the query string, the part of the URL after the ?, as in /api/v1/tasks?status=done&page=2. Each option is a key and a value, and & separates them. The code reads each one by its key, so the names have to match. Add an Index method for the list, and its route:

app/http/controllers/task_controller.go

 package controllers   import ( "github.com/goravel/framework/contracts/http" )   type TaskController struct { // Dependent services }   func NewTaskController() *TaskController { return &TaskController{ // Inject services } }   func (r *TaskController) Store(ctx http.Context) http.Response { return ctx.Response().String(200, "task created") }   func (r *TaskController) Show(ctx http.Context) http.Response { id := ctx.Request().RouteInt("id")   return ctx.Response().Json(200, map[string]any{ "id": id, }) } + +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, + }) +} 

routes/api.go

 package routes   import ( "taskflow/app/facades" "taskflow/app/http/controllers"   "github.com/goravel/framework/contracts/route" )   func Api() { taskController := controllers.NewTaskController()   facades.Route().Prefix("api/v1").Group(func(router route.Router) { router.Post("tasks", taskController.Store).Name("tasks.create") router.Get("tasks/{id}", taskController.Show) + router.Get("tasks", taskController.Index)  }) } 
  • Query reads "status" as a string. If there is no status, you get an empty string.
  • QueryInt reads "page" as a number. Its second argument, 1, is the default when there is no page.
  • QueryMap collects the keys that start with "filters", like filters[priority], into one map.

Add a request with all three:

requests.http

 ### Create task POST http://localhost:3000/api/v1/tasks   ### Show task GET http://localhost:3000/api/v1/tasks/5  +### List tasks +GET http://localhost:3000/api/v1/tasks?status=done&page=2&filters[priority]=high +  ### Hello GET http://localhost:3000/hello 

With curl, add -g. Without it, curl reads the square brackets as a pattern and stops with curl: (3) bad range in URL:

$ curl -g "http://localhost:3000/api/v1/tasks?status=done&page=2&filters[priority]=high" 

GET /api/v1/tasks?status=done&page=2&filters[priority]=high

{ "filters": { "priority": "high" }, "page": 2, "status": "done" } 

The default is used only when page is missing. A page that is not a number gives 0, the same as RouteInt:

$ curl http://localhost:3000/api/v1/tasks {"filters":{},"page":1,"status":""} $ curl "http://localhost:3000/api/v1/tasks?page=abc" {"filters":{},"page":0,"status":""} 

Other ways to read the query string:

Method Query string Returns
QueryInt64("big") ?big=9007199254740993 9007199254740993 as an int64
QueryBool("done") ?done=true true
QueryArray("tag") ?tag=api&tag=urgent ["api","urgent"]
Queries() ?tag=api&tag=urgent&page=2 {"page":"2","tag":"api,urgent"}
BindQuery(&query) ?status=done&page=2 a struct with Status and Page filled in
  • QueryBool also reads 1, yes and on as true. Anything else is false.
  • Queries joins a key that repeats into one string.
  • BindQuery fills a struct from the query string, using form tags.

The next section shows how binding into a struct works.

The request body

Creating a task sends more data: a title and a description. That goes in the request body, usually as JSON. Goravel can read the whole body into a struct. Change Store, the handler of POST /tasks:

app/http/controllers/task_controller.go

 package controllers   import ( "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 { - return ctx.Response().String(200, "task created") + 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 { id := ctx.Request().RouteInt("id")   return ctx.Response().Json(200, map[string]any{ "id": id, }) }   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, }) } 

CreateTaskRequest describes the body we expect. The tags tell Goravel which key goes into which field:

  • json:"title" reads the title key from a JSON body into Title.
  • form:"title" does the same for a form body. With both tags, one struct works for both.

Bind fills the struct from the body. We pass &request, the address of the struct, so that Bind can write into it.

If a value has the wrong type, or the JSON is broken, Bind returns an error and we answer with 400. For now the handler sends the data back, because there is no tasks table to save it in yet.

The request now needs a body, and a header that says the body is JSON:

requests.http

 ### Create task POST http://localhost:3000/api/v1/tasks +Content-Type: application/json + +{ + "title": "Ship v1", + "description": "The first release" +}   ### Show task GET http://localhost:3000/api/v1/tasks/5   ### List tasks GET http://localhost:3000/api/v1/tasks?status=done&page=2&filters[priority]=high   ### Hello GET http://localhost:3000/hello 

Run it, or use curl:

$ 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" } 

A form body gives the same result, because of the form tags:

$ curl -X POST http://localhost:3000/api/v1/tasks -d "title=Ship v1&description=The first release" 

POST /api/v1/tasks

{ "title": "Ship v1", "description": "The first release" } 

A value of the wrong type fails with 400:

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

POST /api/v1/tasks

{ "error": "json: cannot unmarshal number into Go struct field CreateTaskRequest.title of type string" } 

Broken JSON, like a body that stops halfway, also gives 400, but the message does not say what is wrong:

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

POST /api/v1/tasks

{ "error": "http: invalid Read on closed Body" } 

The real reason is in Goravel's log file for the day, storage/logs/goravel-<date>.log:

[2026-10-22 20:42:36.254] local.error: decode json [{"title":] error: unexpected end of JSON input 

Gotcha Send the same JSON without the Content-Type header. You get 200 OK with an empty title and description, and no error. Bind reads the body based on this header. Without -H, curl labels the body as a form, so Bind looks for form fields and finds none. If a body ever comes back empty, check this header first.

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

Other ways to read the body:

Bind reads the whole body at once. To read one value, use Input. For this JSON body:

{"title":"Ship v1","estimate":3,"urgent":true,"tags":["api","urgent"],"meta":{"priority":"high"}} 
Method Returns
Input("title") "Ship v1"
Input("missing", "none") "none"
Input("meta.priority") "high"
InputInt("estimate") 3
InputBool("urgent") true
InputArray("tags") ["api","urgent"]
InputMap("meta") {"priority":"high"}
All() the whole body as a map
  • The second argument of Input is a default for a missing key.
  • A dot reads a key inside an object.
  • Input and All also read the query string, not only the body.
  • Without the Content-Type header, none of these can read the body.

Here are all three requests together, with the code that reads each piece:

Interactive figure on DevChalk: Where a request carries data, and the code that reads each piece.

GET /api/v1/tasks/5: RouteInt("id") reads 5. GET /api/v1/tasks?status=done&page=2&filters[priority]=high: Query("status") reads "done", QueryInt("page", 1) reads 2, QueryMap("filters") reads {"priority":"high"}. POST /api/v1/tasks with a JSON body and Content-Type: application/json: Bind fills Title "Ship v1" and Description "The first release". Without the header, Bind fills nothing and the API still answers 200 OK with empty values.

Listing routes

The API now has three task routes:

$ go run . artisan route:list GET|HEAD / ................................... taskflow/routes.Web.func1 GET|HEAD api/v1/tasks taskflow/app/http/controllers.(*TaskController).Index POST api/v1/tasks tasks.create › taskflow/app/http/controllers.(*TaskController).Store GET|HEAD api/v1/tasks/{id} taskflow/app/http/controllers.(*TaskController).Show GET|HEAD hello ............................... taskflow/routes.Web.func2 STATIC public ........................................................ GET|HEAD users ... taskflow/app/http/controllers.(*UserController).Index Showing [7] routes 

GET and POST on api/v1/tasks are two different routes. Goravel picks one by the method of the request.

TaskFlow's routes now read the path, the query string and the body. Nothing is saved yet, because there is no database.

Previous: 02. Routing & Responses
Next: 04. Models & Migrations
Series: Goravel