# Reading Request Data

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

Goravel series, part 3 of 20. By Krishan Kumar. Published 2026-10-23.
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 & Response 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 route to `routes/api.go`:

```diff lang="go" title="routes/api.go" collapse={1-11} t1:id="{id}" t1:id=/RouteInt.("id")/
 package routes
 
 import (
 	"taskflow/app/facades"
 
 	"github.com/goravel/framework/contracts/http"
 	"github.com/goravel/framework/contracts/route"
 )
 
 func Api() {
 	facades.Route().Prefix("api/v1").Group(func(router route.Router) {
 		router.Post("tasks", func(ctx http.Context) http.Response {
 			return ctx.Response().String(200, "task created")
 		}).Name("tasks.create")
+
+		router.Get("tasks/{id}", func(ctx http.Context) http.Response {
+			id := ctx.Request().RouteInt("id")
+
+			return ctx.Response().Json(200, map[string]any{
+				"id": id,
+			})
+		})
 	})
 }
```

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 a route for the list:

```diff lang="go" title="routes/api.go" collapse={1-22} t1:status=/Query.("status")/ t2:page=/QueryInt.("page")/ t3:filters=/QueryMap.("filters")/
 package routes
 
 import (
 	"taskflow/app/facades"
 
 	"github.com/goravel/framework/contracts/http"
 	"github.com/goravel/framework/contracts/route"
 )
 
 func Api() {
 	facades.Route().Prefix("api/v1").Group(func(router route.Router) {
 		router.Post("tasks", func(ctx http.Context) http.Response {
 			return ctx.Response().String(200, "task created")
 		}).Name("tasks.create")
 
 		router.Get("tasks/{id}", func(ctx http.Context) http.Response {
 			id := ctx.Request().RouteInt("id")
 
 			return ctx.Response().Json(200, map[string]any{
 				"id": id,
 			})
 		})
+
+		router.Get("tasks", func(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,
+			})
+		})
 	})
 }
```

- `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"
{"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.

> **Code** The code for every step of this part is in the tag [`part-03`](https://github.com/krishankumar01/goravel-taskflow/tree/part-03).

## 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 the `POST /tasks` route:

```diff lang="go" title="routes/api.go" collapse={1-9, 28-49} t1:json=/(json:"title")/ t2:form=/(form:"title")/
 package routes
 
 import (
 	"taskflow/app/facades"
 
 	"github.com/goravel/framework/contracts/http"
 	"github.com/goravel/framework/contracts/route"
 )
 
+type CreateTaskRequest struct {
+	Title       string `form:"title" json:"title"`
+	Description string `form:"description" json:"description"`
+}
+
 func Api() {
 	facades.Route().Prefix("api/v1").Group(func(router route.Router) {
 		router.Post("tasks", func(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)
 		}).Name("tasks.create")
 
 		router.Get("tasks/{id}", func(ctx http.Context) http.Response {
 			id := ctx.Request().RouteInt("id")
 
 			return ctx.Response().Json(200, map[string]any{
 				"id": id,
 			})
 		})
 
 		router.Get("tasks", func(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"}'
{"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"
{"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}'
{"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":'
{"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
{"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/routes.Api.func1.3
  POST         api/v1/tasks ....... tasks.create › taskflow/routes.Api.func1.1
  GET|HEAD     api/v1/tasks/{id} ................. taskflow/routes.Api.func1.2
  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.

---

Previous: [02. Routing & Response](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)
