# Reading Request Data

> Path, query string and JSON body, read from the request.

Goravel series, part 3 of 19. By Krishan Kumar. Published 2026-10-23, Updated 2026-10-24.
Source: https://devchalk.com/goravel/03-reading-request-data
Code: https://github.com/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:

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

```diff lang="go" title="app/http/controllers/task_controller.go" collapse={1-16} t1:id=/RouteInt.("id")/
 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`:

```diff lang="go" title="routes/api.go" collapse={1-9} t1:id="{id}"
 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](https://www.goravel.dev/the-basics/routing#routing-parameters). 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:

```diff lang="http" title="requests.http" t1:id=/tasks.(5)/
 ### 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:

```bash t1:id=/tasks.(5)/
$ 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`:

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

```diff lang="go" title="app/http/controllers/task_controller.go" collapse={1-27} t1:status=/Query.("status")/ t2:page=/QueryInt.("page")/ t3:filters=/QueryMap.("filters")/
 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,
+	})
+}
```

```diff lang="go" title="routes/api.go" collapse={1-9}
 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:

```diff lang="http" title="requests.http" t1:status="status=done" t2:page="page=2" t3:filters="filters[priority]=high"
 ### 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`:

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

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

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

```diff lang="go" title="app/http/controllers/task_controller.go" collapse={12-21} collapse={33-52} t1:json=/(json:"title")/ t2:form=/(form:"title")/
 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:

```diff lang="http" title="requests.http" collapse={12-17} t1:json=/("title"):/
 ### 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:

```bash t1:json=/("title"):/
$ 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" t1:json=/("title"):/
{
  "title": "Ship v1",
  "description": "The first release"
}
```

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

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

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

A value of the wrong type fails with `400`:

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

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

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

```json wrap 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`:

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

```json wrap
{"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:

*Where a request carries data, and the code that reads each piece. Pick a request, then take the `Content-Type` header away from Create task.*

GET /api/v1/tasks/5: RouteInt("id") reads 5. GET /api/v1/tasks?status=done&amp;page=2&amp;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:

```bash
$ 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](https://devchalk.com/goravel/02-routing-response.md)
Next: [04. Models & Migrations](https://devchalk.com/goravel/04-models-migrations.md)
Series: [Goravel](https://devchalk.com/goravel.md)
