# DevChalk
> Backend tutorials where each series builds one real app, from an empty folder to production.
# How to Read DevChalk
> What each part of a page does: the code blocks, matching colours, boxes, figures and tools, and the Markdown copies.
By Krishan Kumar. Published 2026-10-24, Updated 2026-10-25.
Source: https://devchalk.com/articles/how-to-read
Each series builds one app from an empty folder, a part at a time. This page shows what each piece of an article does. The examples are real blocks from the [Goravel series](https://devchalk.com/goravel).
## Reading a series
Parts build on each other, so read them in order. Each series page lists the parts that are written and the ones still to come. New parts also come out on the [RSS feed](https://devchalk.com/rss.xml).
You can still start at any part. Each part after the first opens with a block like this one, from [Validation with Form Requests](https://devchalk.com/goravel/08-validation-form-requests). It gets you the code as the part before left it:
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
```
Each series has its own repository, with one commit per step and a tag for each part: `part-01`, `part-02`, and so on. For Goravel, it is [goravel-taskflow](https://github.com/krishankumar01/goravel-taskflow). The box at the end of each part links to its tag, to its changes since the part before, and to a zip.
## Code blocks
The name on top of a block is the file it belongs to. **Copy** copies the code. In Go blocks it copies tabs, the way `gofmt` writes Go, so pasted code is already formatted.
A block that changes a file shows the whole file. Lines marked `+` are added, and lines marked `-` are removed. Most of the lines you don't touch are folded away; click a fold to open it. **Copy** gives the file as it should end up, with the folded lines and without the removed ones. These blocks from [Reading Request Data](https://devchalk.com/goravel/03-reading-request-data#route-parameters) add a method to a controller, and its route:
```diff lang="go" title="app/http/controllers/task_controller.go" collapse={1-16} t1:name=/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,
+ })
+}
```
```diff lang="go" title="routes/api.go" collapse={1-9} t1:name="{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)
})
}
```
Code keeps its lines as written, so a long line scrolls sideways. Query logs wrap instead, so you can read them without scrolling. The server sends a response on one line, as the terminal blocks show. Response blocks show it indented, for reading.
In a terminal block, a line that starts with `$` is a command, and the dimmed lines are what it prints. **Copy** copies only the commands:
```bash
$ go run . artisan migrate
INFO Running migration
INFO Running: 20210101000002_create_jobs_table
INFO Running: 20261022221906_create_users_table
SUCCESS Migration success
```
## Boxes in the text
Each kind of box has its own label and icon:
- A **Gotcha** box, in red, is something that trips you up, with the output that shows it.
- A **Note** box is a side fact, such as another place the code could go.
- A **More** box opens to a side trip, such as another way to do a step or a measurement. You can skip it on a first read.
- A **Code** box gets you the code. At the start of a part it opens to the commands that set up the part before, and at the end it links to the part's tag, its changes and a zip.
## Matching colours
When a word in the text has the same colour as a piece of code, they are the same thing. Here, the `{id}` in the path is what `"id"` reads in `Show` above. The `5` at the end of this request is the value it gets:
```bash wrap t2:value=/tasks.(5)/
$ curl http://localhost:3000/api/v1/tasks/5
{"id":5}
```
Point at a coloured word to light every piece in its group, so you can follow them without telling the colours apart. Click or tap it to keep it lit and go to the next piece, which scrolls into view if it is off the screen. Click anywhere else to let it go.
## Figures
A figure shows how something works, such as what the code does as it runs, or how the tables connect. Some are drawings. On the table design in [Installation & Project Architecture](https://devchalk.com/goravel/01-installation-project-architecture#the-database-design), pointing at a line lights the two columns it joins.
Others have controls along the bottom. A row of choices picks a case, and **Previous** and **Next** go through it one step at a time. The chip in the corner shows the result, and the text under the drawing says what is going on. This one is from [Validation with Form Requests](https://devchalk.com/goravel/08-validation-form-requests):
*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().
The arrow keys step too, once you have clicked the figure. A longer figure has a few more controls. This one, from [Register & Login with JWT](https://devchalk.com/goravel/09-register-login-jwt), follows one session through TaskFlow:
- **Next** says what it will do, such as "Next: look up the email".
- The bar above the buttons shows where you are. Click a name on it to jump to the start of that request.
- **Play** steps on its own, at reading pace, until you pause it or press a button.
- Click an arrow or a box you have already passed to go back to it.
*One session against TaskFlow, one message at a time.*
Register: the API hashes the password with bcrypt and inserts one row into users. Log in: the API selects the user by email, checks the password against the hash, and signs a token with JWT_SECRET; nothing is written to Postgres or Redis. /me: the API asks Redis whether the token was logged out, checks the signature, and loads the user by the id in the token. Log out: the API writes the token into Redis as disabled. /me again: Redis has the token, so the API answers 401 before checking the signature.
Without JavaScript, each figure is replaced by a short text that says the same thing.
## Tools
Some parts give you a tool to use on your own project, not just on the article's example. In [Register & Login with JWT](https://devchalk.com/goravel/09-register-login-jwt), you paste the token your login returned, and the page takes it apart and checks its signature with your secret. Tools run in your browser, and what you paste is never stored or sent anywhere. Even so, use a local secret, not a production one.
## Jumping to a section
On a wide screen, the list beside each article marks the section you are reading. On a phone, it sits above the article. Click a heading in it to jump there.
## Markdown copies for AI assistants
Every article has a plain Markdown copy at the same address with `.md` added, like [/goravel/08-validation-form-requests.md](https://devchalk.com/goravel/08-validation-form-requests.md). It has the same words, the code exactly as written, full links, and each figure as its text. Give it to an AI assistant when you want help with a part.
[/llms.txt](https://devchalk.com/llms.txt) lists every copy, and [/llms-full.txt](https://devchalk.com/llms-full.txt) has every article in one file.
# Installation & Project Architecture
> A new Goravel project, Postgres and Redis in Docker, the server running.
Goravel series, part 1 of 20. By Krishan Kumar. Published 2026-03-08, Updated 2026-10-24.
Source: https://devchalk.com/goravel/01-installation-project-architecture
Code: https://github.com/krishankumar01/goravel-taskflow/tree/part-01
This is the first part of the [Goravel series](https://devchalk.com/goravel). We are going to build TaskFlow, a task API for teams, and learn Goravel as we go. In this part we plan the database, create the project, start Postgres and Redis in Docker, and get the server running. [How to Read DevChalk](https://devchalk.com/articles/how-to-read) shows what the code blocks and figures on these pages do.
Clone the finished code for this part:
```bash
git clone https://github.com/krishankumar01/goravel-taskflow
cd goravel-taskflow
git checkout part-01
cp .env.example .env
go run . artisan key:generate
docker compose up -d
```
## The database design
Before we write any code, let's look at the data. The app needs three tables.
**users** holds everyone who can log in. Next to the name, email and password, it has a `role`, which is either `manager` or `member`. We use this column for permissions later. A manager can create, edit and delete tasks. A member can only change the status of tasks assigned to them.
| column | type | notes |
|---|---|---|
| `id` | bigint | primary key |
| `name` | string | |
| `email` | string | unique |
| `password` | string | hashed, never plain |
| `role` | enum | `manager` or `member`, default `member` |
**tasks** is the main table. A task has a title, a description, an optional due date and a `status` that is one of `pending`, `in-progress` or `done`. It links to the users table twice. `created_by` is the manager who made the task, and `assigned_to` is the person who has to finish it. The permission rules use both.
| column | type | notes |
|---|---|---|
| `id` | bigint | primary key |
| `created_by` | bigint | references `users.id` |
| `assigned_to` | bigint | references `users.id`, nullable |
| `title` | string | |
| `description` | text | |
| `status` | enum | `pending`, `in-progress`, `done`, default `pending` |
| `due_at` | timestamp | nullable, when the task is due |
**notifications** tells a user that something changed. When a task changes, for example when it is assigned to someone, that person gets a row here. `read_at` is null until the user has seen it.
| column | type | notes |
|---|---|---|
| `id` | bigint | primary key |
| `user_id` | bigint | references `users.id` |
| `task_id` | bigint | references `tasks.id` |
| `message` | string | |
| `read_at` | timestamp | nullable, null means unread |
So a user can create many tasks, be assigned many tasks, and have many notifications.
*TaskFlow's three tables. Point at a line, or tap it, to highlight the two columns it joins.*
tasks.created_by and tasks.assigned_to both reference users.id; assigned_to may be empty. notifications.user_id references users.id, and notifications.task_id references tasks.id.
## Check Go and install the Goravel CLI
You need Go and [Docker](https://docs.docker.com/get-started/get-docker/). Goravel v1.18 needs Go 1.25 or newer. Check what you have:
```bash
$ go version
go version go1.27.3 darwin/arm64
```
If Go is missing or older, install the current release from [go.dev](https://go.dev/dl/).
Goravel has an [installer](https://github.com/goravel/installer) for new projects, like the [Laravel installer](https://laravel.com/docs/12.x/installation). Install it and run it once:
```bash
go install github.com/goravel/installer/goravel@latest
goravel
```
If you see a help screen, it worked. If you see `command not found`, the Go binary directory is not on your `PATH`. Add this to your shell config (`~/.zshrc` or `~/.bashrc`), then restart the terminal:
```bash
export PATH=$PATH:$(go env GOPATH)/bin
```
## Create the project
The installer creates the project and asks a few questions as it goes:
```bash
goravel new
```
1. **Project name**: `taskflow`.
2. **Version**: pick **[Goravel Lite](https://github.com/goravel/goravel-lite)**. It is a slimmer skeleton where you choose only the facades you need. The full version installs everything.
3. **Module name**: `taskflow`. This is the `module` line in `go.mod`, and the start of every import path, like `taskflow/routes`.
It clones the skeleton and downloads the dependencies. Lite starts with only Artisan, Config and Process, so it then runs [`package:install`](https://www.goravel.dev/getting-started/installation) and asks which facades to add. Press Space to select one and Enter when you are done.
Each facade you pick also installs the facades it depends on, so these four are enough for TaskFlow:
- **Route**, to define API routes. It also installs Log, Cache, Hash, Http, RateLimiter, Session, Storage, Validation and View.
- **Auth**, for login. It also installs Orm and Schema.
- **Gate**, for authorization rules.
- **Queue**, for background jobs. It also installs DB.
Some facades need a driver, and the installer asks for each one:
- **Cache**: Redis. The memory driver keeps data inside the process and loses it on every restart.
- **Session**: Redis, the same as Cache. File works too.
- **Storage**: Local. Files on disk are enough until we deploy.
- **Route**: [Gin](https://github.com/goravel/gin). [Fiber](https://github.com/goravel/fiber) is also supported, and your code stays almost the same with either.
- **Database**: Postgres, for Orm and DB. MySQL, SQL Server and SQLite are also supported.
- **Queue**: Redis. Sync runs a job right away, inside the request. Database stores jobs in a table. Redis is the usual choice for a real queue.
When it finishes, go into the project:
```bash
cd taskflow
```
## The project structure
If you know [Laravel](https://laravel.com), the layout will look familiar.
- `app/` is the application: controllers, models, jobs, services. It also holds the facades. They are plain files in your project, so you can add your own. Most of our code goes here.
- `bootstrap/app.go` is startup: service providers, route registration, migrations.
- `config/` has a file per area: app, auth, cache, database and so on. You rarely edit these directly. They read their values from `.env`.
- `routes/` holds the route files. It starts with `web.go`, and we add more in later parts.
- `main.go` is the entry point. It boots the app using `bootstrap.Boot()` and starts the server.
- `taskflow/`
- **`app/`** *your code: controllers, models, jobs*
- `facades/` *Route(), Orm(), Cache() and 17 more, one small file each, yours to edit*
- `http/controllers/` *request handlers; user_controller.go is a sample*
- `bootstrap/`
- **`app.go`** *wires the app together: providers, route files, migrations*
- `providers.go` *the service providers package:install registered*
- **`config/`** *12 files: app, auth, cache, database, queue… values come from .env*
- `database/migrations/` *schema changes; Queue's jobs table is already here*
- `resources/views/` *HTML templates; welcome.tmpl is the page at /*
- **`routes/`** *route files; Goravel loads only the ones app.go calls*
- `web.go` *GET / and GET /users*
- `.env` *your machine's settings: database, Redis, APP_KEY. Not in Git*
- `docker-compose.yml` *Postgres and Redis, set up in the next section*
- `go.mod` *module taskflow, Goravel v1.18*
- **`main.go`** *the entry point: boots the app, then starts the HTTP server*
*The five from the list are in bold.*
## Postgres and Redis with Docker Compose
We chose [Postgres](https://www.postgresql.org) and [Redis](https://redis.io), so we need both running. The app itself runs with Go on your machine, not inside Docker, because restarts are faster that way. Docker runs only the two services.
The skeleton comes with a `docker-compose.yml`. Replace what is in it with the two services:
```yaml title="docker-compose.yml" t1:db-port=/"(5433):/ t2:redis-port=/"(6380):/ t3:db-login=/POSTGRES_DB: (taskflow)/ t3:db-login=/POSTGRES_USER: (goravel)/ t3:db-login=/POSTGRES_PASSWORD: (secret)/
services:
postgres:
image: postgres:15-alpine
restart: always
environment:
POSTGRES_DB: taskflow
POSTGRES_USER: goravel
POSTGRES_PASSWORD: secret
ports:
- "5433:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
redis:
image: redis:alpine
restart: always
ports:
- "6380:6379"
volumes:
- redis_data:/data
volumes:
postgres_data:
redis_data:
```
The [port mapping](https://docs.docker.com/reference/compose-file/services/#ports) is `host:container`. I already have Postgres on 5432 and Redis on 6379 locally, so I map 5433 and 6380 on the host to the standard ports inside the containers. If you have nothing running on those ports, `5432:5432` and `6379:6379` are simpler. If you use those mappings, set `DB_PORT=5432` and `REDIS_PORT=6379` in `.env` in the next section.
The [named volumes](https://docs.docker.com/reference/compose-file/volumes/) keep the data on disk, so stopping or recreating a container does not wipe the database.
*What `host:container` means. Change the mapping, or turn on "Postgres already installed", and see where the app's connection lands.*
With "5433:5432" the container's 5432 answers on your machine's 5433, so DB_PORT=5433 and a local Postgres keeps 5432. With "5432:5432" and nothing else on 5432, DB_PORT=5432 works. With "5432:5432" while a local Postgres holds 5432, docker compose up fails: port is already allocated.
Make sure Docker is running, then start them in the background:
```bash
docker compose up -d
docker ps
```
You should see two containers, `taskflow-postgres-1` and `taskflow-redis-1`. The names start with your folder name, so if you cloned the repo they start with `goravel-taskflow-`. Both should have a STATUS of `Up`, and the PORTS column should show `0.0.0.0:5433->5432/tcp` and `0.0.0.0:6380->6379/tcp`, or whatever ports you mapped.
## Environment configuration
The installer already made a `.env` file, and the app reads its settings from it. Change these lines to match `docker-compose.yml` and leave the rest of the file as it is:
```dotenv title=".env" t1:db-port=/DB_PORT=(5433)/ t2:redis-port=/REDIS_PORT=(6380)/ t3:db-login=/DB_DATABASE=(taskflow)/ t3:db-login=/DB_USERNAME=(goravel)/ t3:db-login=/DB_PASSWORD=(secret)/
DB_HOST=127.0.0.1
DB_PORT=5433
DB_DATABASE=taskflow
DB_USERNAME=goravel
DB_PASSWORD=secret
REDIS_HOST=127.0.0.1
REDIS_PASSWORD=
REDIS_PORT=6380
```
## Run it
With the containers up, start the app:
```bash
$ go run .
INFO Processing jobs from [redis] connection and [default] queue
GET|HEAD / ................................... taskflow/routes.Web.func1
STATIC public ........................................................
GET|HEAD users ... taskflow/app/http/controllers.(*UserController).Index
Showing [3] routes
[HTTP] Listening on: http://127.0.0.1:3000
```
It builds the app from `main.go`, starts a queue worker on Redis and listens on port 3000. It also prints the routes the app has. These three come with the skeleton. Open [http://localhost:3000](http://localhost:3000) to see the Goravel welcome page. Press Ctrl+C to stop it.
Goravel has a command line for jobs like migrations and generating code, called [Artisan](https://www.goravel.dev/digging-deeper/artisan-console), as in Laravel:
```bash
go run . artisan
```
This lists all the commands, such as `db` commands, `make` commands that generate code, and `migrate` commands.
The server starts even if the database settings are wrong, so check Postgres with one of the `db` commands:
```bash t1:db-port=/Port [.]+ (5433)/ t3:db-login=/Database [.]+ (taskflow)/ t3:db-login=/Username [.]+ (goravel)/
$ go run . artisan db:show
PostgreSQL .................................................................
Database .......................................................... taskflow
Host ............................................................. 127.0.0.1
Port .................................................................. 5433
Username ........................................................... goravel
Open Connections ......................................................... 1
Tables ................................................................... 0
```
It shows the database the app connects to. There are no tables yet. If `.env` does not match `docker-compose.yml`, you get `connection refused` instead.
TaskFlow now runs, with Postgres and Redis behind it. It has no routes of its own yet.
---
Next: [02. Routing & Responses](https://devchalk.com/goravel/02-routing-response.md)
Series: [Goravel](https://devchalk.com/goravel.md)
# Routing & Responses
> Route files registered, JSON responses shaped, a task controller.
Goravel series, part 2 of 20. By Krishan Kumar. Published 2026-03-13, Updated 2026-10-25.
Source: https://devchalk.com/goravel/02-routing-response
Code: https://github.com/krishankumar01/goravel-taskflow/tree/part-02
TaskFlow runs, but it does nothing yet. In this part we add routes, send back text, HTML and JSON, move the API routes into their own file, and move their handlers into a controller. Before we start, let's set up a tool that restarts the server for you.
Start from the Installation & Project Architecture code:
```bash
git clone https://github.com/krishankumar01/goravel-taskflow
cd goravel-taskflow
git checkout part-01
cp .env.example .env
go run . artisan key:generate
docker compose up -d
```
## Hot reload with Air
Right now you have to restart the server after every change. [Air](https://github.com/air-verse/air) watches the project and restarts it for you.
```bash
$ go install github.com/air-verse/air@latest
$ air -v
__ _ ___
/ /\ | | | |_)
/_/--\ |_| |_| \_
v1.67.4, built with Go go1.27.3
```
If `go run .` is still running, stop it with Ctrl+C. From now on, start the app with Air instead:
```bash
air
```
Air watches `.go`, `.tpl`, `.tmpl` and `.html` files. Change a route file, save, and the server restarts by itself.
Air's settings are in `.air.toml` at the project root. This is the part that matters here:
```toml title=".air.toml"
[build]
cmd = "go build -o ./tmp/main.exe ."
delay = 1000
exclude_dir = ["storage", "database", "tmp"]
include_ext = ["go", "tpl", "tmpl", "html"]
```
`delay` is how many milliseconds Air waits after a save before it rebuilds. You can leave it, or set it to `500` to restart faster.
## The route file
Open `routes/web.go`. Routes are registered through the [`Route` facade](https://www.goravel.dev/the-basics/routing), with a method per HTTP verb. The skeleton already has a few. The highlighted one returns the welcome page:
```go title="routes/web.go" showLineNumbers {12-16}
package routes
import (
"github.com/goravel/framework/contracts/http"
"github.com/goravel/framework/support"
"taskflow/app/http/controllers"
"taskflow/app/facades"
)
func Web() {
facades.Route().Get("/", func(ctx http.Context) http.Response {
return ctx.Response().View().Make("welcome.tmpl", map[string]any{
"version": support.Version,
})
})
facades.Route().Static("public", "./public")
userController := controllers.NewUserController()
facades.Route().Get("/users", userController.Index)
}
```
It returns a view, an HTML template from `resources/views`.
The first argument is the path and the second is the handler. A handler takes a Goravel `http.Context` and returns an `http.Response`. Every handler in this series looks like this. The leading slash in the path is optional. `hello` and `/hello` are the same route.
## Response types
A route can answer with plain text, HTML or JSON. Start with plain text:
```diff lang="go" title="routes/web.go" collapse={1-19}
package routes
import (
"github.com/goravel/framework/contracts/http"
"github.com/goravel/framework/support"
"taskflow/app/http/controllers"
"taskflow/app/facades"
)
func Web() {
facades.Route().Get("/", func(ctx http.Context) http.Response {
return ctx.Response().View().Make("welcome.tmpl", map[string]any{
"version": support.Version,
})
})
facades.Route().Static("public", "./public")
userController := controllers.NewUserController()
facades.Route().Get("/users", userController.Index)
+
+ facades.Route().Get("hello", func(ctx http.Context) http.Response {
+ return ctx.Response().String(200, "Hello, world")
+ })
}
```
Save, let Air restart, and check it with curl in a second terminal, since Air keeps the first one busy. The `-i` flag prints the headers too:
```bash
$ curl -i http://localhost:3000/hello
HTTP/1.1 200 OK
Content-Type: text/plain; charset=utf-8
Hello, world
```
The content type is `text/plain`. To send HTML instead, set the `Content-Type` header:
```diff lang="go" title="routes/web.go" collapse={1-21}
package routes
import (
"github.com/goravel/framework/contracts/http"
"github.com/goravel/framework/support"
"taskflow/app/http/controllers"
"taskflow/app/facades"
)
func Web() {
facades.Route().Get("/", func(ctx http.Context) http.Response {
return ctx.Response().View().Make("welcome.tmpl", map[string]any{
"version": support.Version,
})
})
facades.Route().Static("public", "./public")
userController := controllers.NewUserController()
facades.Route().Get("/users", userController.Index)
facades.Route().Get("hello", func(ctx http.Context) http.Response {
- return ctx.Response().String(200, "Hello, world")
+ return ctx.Response().Header("Content-Type", "text/html").String(200, "
Hello, world
")
})
}
```
Open [localhost:3000/hello](http://localhost:3000/hello) in the browser and the text shows as a heading.
For an API you usually want JSON. `Json` takes a status code and any value. A map is the simplest:
```diff lang="go" title="routes/web.go" collapse={1-21}
package routes
import (
"github.com/goravel/framework/contracts/http"
"github.com/goravel/framework/support"
"taskflow/app/http/controllers"
"taskflow/app/facades"
)
func Web() {
facades.Route().Get("/", func(ctx http.Context) http.Response {
return ctx.Response().View().Make("welcome.tmpl", map[string]any{
"version": support.Version,
})
})
facades.Route().Static("public", "./public")
userController := controllers.NewUserController()
facades.Route().Get("/users", userController.Index)
facades.Route().Get("hello", func(ctx http.Context) http.Response {
- return ctx.Response().Header("Content-Type", "text/html").String(200, "Hello, world
")
+ return ctx.Response().Json(200, map[string]any{
+ "message": "Hello, world",
+ })
})
}
```
Reload `/hello` and you get:
```json title="GET /hello"
{
"message": "Hello, world"
}
```
There are [other response types](https://www.goravel.dev/the-basics/response) too:
- `File` sends a file.
- `Data` sends raw bytes with a content type.
- `Stream` sends data to the client as it is produced.
## Grouping routes under a prefix
Every API route in this series lives under `/api/v1`. If we ever make a v2, it can live next to v1, and apps that still call v1 keep working. Instead of writing the prefix on each route, put it on a [group](https://www.goravel.dev/the-basics/routing#group-routing):
```diff lang="go" title="routes/web.go" collapse={8-28}
package routes
import (
"github.com/goravel/framework/contracts/http"
+ "github.com/goravel/framework/contracts/route"
"github.com/goravel/framework/support"
"taskflow/app/http/controllers"
"taskflow/app/facades"
)
func Web() {
facades.Route().Get("/", func(ctx http.Context) http.Response {
return ctx.Response().View().Make("welcome.tmpl", map[string]any{
"version": support.Version,
})
})
facades.Route().Static("public", "./public")
userController := controllers.NewUserController()
facades.Route().Get("/users", userController.Index)
facades.Route().Get("hello", func(ctx http.Context) http.Response {
return ctx.Response().Json(200, map[string]any{
"message": "Hello, world",
})
})
+
+ 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")
+ })
+ })
}
```
Everything registered on `router` gets the prefix. `POST` is the method for creating things. We send real task data in a later part.
## Testing with the HTTP client
You cannot make a `POST` from the address bar. [Postman](https://www.postman.com) and [curl](https://curl.se) both work. In this series I use the [HTTP client built into GoLand](https://www.jetbrains.com/help/go/http-client-in-product-code-editor.html), because the requests live in the project next to the code. Create `requests.http` in the project root:
```http title="requests.http"
### Create task
POST http://localhost:3000/api/v1/tasks
### Hello
GET http://localhost:3000/hello
```
Each request starts with `###` and a name, then the method and the URL. Note the prefix on the tasks route.
Click the run icon next to a request and the response opens in the editor. In VS Code, the REST Client extension runs the same file. Commit it with the code, and everyone on the project gets the same requests.
Without GoLand, use curl:
```bash
$ curl -X POST http://localhost:3000/api/v1/tasks
task created
```
## Named routes
A route can have a [name](https://www.goravel.dev/the-basics/routing#set-route-name). Your code can then find the route by its name instead of its path:
```diff lang="go" title="routes/web.go" collapse={1-30} t1:route-name="tasks.create"
package routes
import (
"github.com/goravel/framework/contracts/http"
"github.com/goravel/framework/contracts/route"
"github.com/goravel/framework/support"
"taskflow/app/http/controllers"
"taskflow/app/facades"
)
func Web() {
facades.Route().Get("/", func(ctx http.Context) http.Response {
return ctx.Response().View().Make("welcome.tmpl", map[string]any{
"version": support.Version,
})
})
facades.Route().Static("public", "./public")
userController := controllers.NewUserController()
facades.Route().Get("/users", userController.Index)
facades.Route().Get("hello", func(ctx http.Context) http.Response {
return ctx.Response().Json(200, map[string]any{
"message": "Hello, world",
})
})
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")
})
}
```
Ask the router about the route by its name:
```diff lang="go" title="routes/web.go" collapse={1-22, 31-36} t1:route-name="tasks.create"
package routes
import (
"github.com/goravel/framework/contracts/http"
"github.com/goravel/framework/contracts/route"
"github.com/goravel/framework/support"
"taskflow/app/http/controllers"
"taskflow/app/facades"
)
func Web() {
facades.Route().Get("/", func(ctx http.Context) http.Response {
return ctx.Response().View().Make("welcome.tmpl", map[string]any{
"version": support.Version,
})
})
facades.Route().Static("public", "./public")
userController := controllers.NewUserController()
facades.Route().Get("/users", userController.Index)
facades.Route().Get("hello", func(ctx http.Context) http.Response {
- return ctx.Response().Json(200, map[string]any{
- "message": "Hello, world",
- })
+ return ctx.Response().Json(200, facades.Route().Info("tasks.create"))
})
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")
})
}
```
Open `/hello` and you see the route's path, name, method and handler:
```json title="GET /hello" t1:route-name=/"name": "(tasks.create)"/
{
"handler": "taskflow/routes.Web.func3.1",
"method": "POST",
"name": "tasks.create",
"path": "/api/v1/tasks"
}
```
If the path changes later, code that uses the name keeps working.
## Separate the API from web routes
`routes/web.go` is for pages, public files and test routes like `/hello`. API routes are easier to manage in their own file. Create `routes/api.go`:
```go title="routes/api.go" t2:api=/func (Api)[(]/
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")
})
}
```
Move the API routes out of `web.go`:
```diff lang="go" title="routes/web.go" collapse={8-23}
package routes
import (
"github.com/goravel/framework/contracts/http"
- "github.com/goravel/framework/contracts/route"
"github.com/goravel/framework/support"
"taskflow/app/http/controllers"
"taskflow/app/facades"
)
func Web() {
facades.Route().Get("/", func(ctx http.Context) http.Response {
return ctx.Response().View().Make("welcome.tmpl", map[string]any{
"version": support.Version,
})
})
facades.Route().Static("public", "./public")
userController := controllers.NewUserController()
facades.Route().Get("/users", userController.Index)
facades.Route().Get("hello", func(ctx http.Context) http.Response {
return ctx.Response().Json(200, facades.Route().Info("tasks.create"))
})
-
- 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")
- })
}
```
> **Gotcha** When you save, `POST /api/v1/tasks` stops working, and there is no error message. The route is gone, so the answer should be `404 Not Found`. The client gets `200 OK` with an empty body instead, so it looks like the request worked. The only sign is the 404 in the server's request log. `/hello` also returns empty fields until you register the file in the next step.
Goravel does not discover route files. You register them in `bootstrap/app.go`:
```diff lang="go" title="bootstrap/app.go" collapse={1-12} t2:api=/routes[.](Api)[(]/
package bootstrap
import (
contractsfoundation "github.com/goravel/framework/contracts/foundation"
"github.com/goravel/framework/foundation"
"taskflow/config"
"taskflow/routes"
)
func Boot() contractsfoundation.Application {
return foundation.Setup().
WithMigrations(Migrations).
WithRouting(func() {
+ routes.Api()
routes.Web()
}).
WithProviders(Providers).
WithConfig(config.Boot).
Create()
}
```
*One route table for the whole app. Send the request, then register `api.go` and send it again.*
Until bootstrap/app.go calls routes.Api(), the route table has only the routes from web.go, and POST /api/v1/tasks matches nothing: the client gets 200 OK with an empty body and the server logs a 404. After routes.Api() is added, the request matches and the handler answers "task created".
Save again, then send the `POST` and open `/hello`. Both answer again. Every new route file needs a line like this in `bootstrap/app.go`.
## Controllers
The create task handler is in `routes/api.go`. Every part adds routes, so the file would soon hold the whole API. A [controller](https://www.goravel.dev/the-basics/controllers) is a struct whose methods are handlers, and the route file keeps only the list of routes. The project came with one: `UserController`, behind `/users` in `web.go`.
Make one for tasks:
```bash
$ go run . artisan make:controller TaskController
SUCCESS Controller created successfully
```
It writes `app/http/controllers/task_controller.go`:
```go title="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) Index(ctx http.Context) http.Response {
return nil
}
```
> **Gotcha** The name you give becomes the type, and its snake_case form the file name. `make:controller Task` writes `task.go` with a type `Task`, so end the name in `Controller`.
A method takes `ctx http.Context` and returns `http.Response`, like any handler. Replace `Index` with `Store`, and move the create task handler's body into it:
```diff lang="go" title="app/http/controllers/task_controller.go" collapse={1-16} t3:store=/ (Store)[(]/
package controllers
import (
"github.com/goravel/framework/contracts/http"
)
type TaskController struct {
// Dependent services
}
func NewTaskController() *TaskController {
return &TaskController{
// Inject services
}
}
-func (r *TaskController) Index(ctx http.Context) http.Response {
- return nil
-}
+func (r *TaskController) Store(ctx http.Context) http.Response {
+ return ctx.Response().String(200, "task created")
+}
```
Controller methods are named after what they do:
| method | route | does |
| --- | --- | --- |
| `Index` | `GET /tasks` | lists them |
| `Show` | `GET /tasks/{id}` | shows one |
| `Store` | `POST /tasks` | creates one |
| `Update` | `PUT /tasks/{id}` | changes one |
| `Destroy` | `DELETE /tasks/{id}` | deletes one |
Point the route at the method:
```diff lang="go" title="routes/api.go" t3:store=/taskController[.](Store)/ t1:route-name="tasks.create"
package routes
import (
"taskflow/app/facades"
+ "taskflow/app/http/controllers"
- "github.com/goravel/framework/contracts/http"
"github.com/goravel/framework/contracts/route"
)
func Api() {
+ taskController := controllers.NewTaskController()
+
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.Post("tasks", taskController.Store).Name("tasks.create")
})
}
```
`NewTaskController` runs once, when the routes are registered, so every request shares the one controller. Keep a request's data in local variables, never in the struct's fields.
The `POST` still answers `task created`, and `/hello` now names the method:
```bash
$ curl http://localhost:3000/hello
```
```json title="GET /hello" t1:route-name=/"name": "(tasks.create)"/ t3:store=/[.](Store)-fm/
{
"handler": "taskflow/app/http/controllers.(*TaskController).Store-fm",
"method": "POST",
"name": "tasks.create",
"path": "/api/v1/tasks"
}
```
Go adds `-fm` to the name of a method used as a value.
Resource controllers: every route in one line:
`make:controller --resource TaskController` writes all five methods from the table, and `router.Resource("tasks", taskController)` registers their routes in one line, with `PATCH` for `Update` too. See [resource controllers](https://www.goravel.dev/the-basics/controllers#resource-controllers).
That line is one entry with one name for all six routes, so the create route can't be named `tasks.create` on its own. TaskFlow keeps one line per route.
## Listing routes
To check which routes are registered, run:
```bash t1:route-name=/ (tasks.create) / t3:store=/[)][.](Store)/
$ go run . artisan route:list
GET|HEAD / ................................... taskflow/routes.Web.func1
POST api/v1/tasks tasks.create › taskflow/app/http/controllers.(*TaskController).Store
GET|HEAD hello ............................... taskflow/routes.Web.func2
STATIC public ........................................................
GET|HEAD users ... taskflow/app/http/controllers.(*UserController).Index
Showing [5] routes
```
It prints every registered route with its method, path, name and handler. `route:list` leaves the `-fm` off. Run it when a route does not answer and you do not know why.
TaskFlow now has its first API route, in its own file and handled by a controller. It doesn't read anything the client sends yet.
---
Previous: [01. Installation & Project Architecture](https://devchalk.com/goravel/01-installation-project-architecture.md)
Next: [03. Reading Request Data](https://devchalk.com/goravel/03-reading-request-data.md)
Series: [Goravel](https://devchalk.com/goravel.md)
# 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, 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-.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&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:
```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)
# Models & Migrations
> A users table, generated from the model.
Goravel series, part 4 of 20. By Krishan Kumar. Published 2026-10-23, Updated 2026-10-24.
Source: https://devchalk.com/goravel/04-models-migrations
Code: https://github.com/krishankumar01/goravel-taskflow/tree/part-04
TaskFlow reads requests, but it has nowhere to keep anything yet. In this part we create the users table from the [database design](https://devchalk.com/goravel/01-installation-project-architecture#the-database-design). We write a model, and Goravel writes the migration from it.
Start from the Reading Request Data code:
```bash
git clone https://github.com/krishankumar01/goravel-taskflow
cd goravel-taskflow
git checkout part-03
cp .env.example .env
go run . artisan key:generate
docker compose up -d
```
## The model
A model is a Go struct that stands for one table. Create one for users:
```bash
$ go run . artisan make:model User
SUCCESS Model created successfully
```
This creates `app/models/user.go`:
```go title="app/models/user.go"
package models
import (
"github.com/goravel/framework/database/orm"
)
type User struct {
orm.Model
}
```
[`orm.Model`](https://www.goravel.dev/orm/getting-started) gives every model an id and two timestamps: when the row was created and when it was last updated. Goravel fills the timestamps for you when a row is saved. A model named `User` uses a table named `users`.
Now add the fields from the design:
```diff lang="go" title="app/models/user.go" t1:model="orm.Model" t2:email=/(Email) +string/ t3:role=/(Role) +Role +/
package models
import (
"github.com/goravel/framework/database/orm"
)
+type Role string
+
+const (
+ RoleManager Role = "manager"
+ RoleMember Role = "member"
+)
+
type User struct {
orm.Model
+ Name string `gorm:"size:255;not null"`
+ Email string `gorm:"size:255;unique;not null"`
+ Password string `gorm:"size:255;not null"`
+ Role Role `gorm:"type:enum('manager','member');default:'member';not null"`
}
```
- `Email` is unique, so two users can never share an email.
- `Role` has its own type with two values, manager and member. If no role is given, the database uses member.
- In code you write `models.RoleManager` instead of the string `"manager"`. If you mistype `RoleManager`, the build fails and tells you. If you mistype `"manager"`, it builds fine and the bug shows up later.
The [GORM tags](https://gorm.io/docs/models.html#tags) after each field describe its column: `size:255` is the maximum length, and `not null` says it must have a value. `Password` will hold a hashed password, never the real one. We hash it when we build register and login.
Goravel does not create or change tables from these tags while the app runs. The tags only matter when `make:migration` reads them to write a migration.
Other tags make:migration understands:
Here is a model that tries a different tag on each field. It is only an example, so do not add it to TaskFlow:
```go title="app/models/probe.go"
type Probe struct {
orm.Model
Title string `gorm:"size:100"`
Body string `gorm:"type:text"`
Nick *string `gorm:"size:50"`
Points int `gorm:"default:0"`
Done bool `gorm:"default:false"`
Code string `gorm:"size:20;uniqueIndex"`
Status string `gorm:"size:20;index"`
Note string `gorm:"size:255;comment:shown to the user"`
DueAt *string `gorm:"column:due_on;size:10"`
Price float64 `gorm:"type:decimal;precision:10;scale:2"`
Meta string `gorm:"migration:json"`
}
```
And what `make:migration -m Probe` wrote for it:
```go
return facades.Schema().Create("probes", func(table schema.Blueprint) {
table.TimestampTz("created_at").Nullable()
table.TimestampTz("updated_at").Nullable()
table.BigIncrements("id")
table.String("title", 100)
table.Text("body")
table.String("nick", 50).Nullable()
table.BigInteger("points").Default(0)
table.Boolean("done").Default(false)
table.String("code", 20)
table.String("status", 20)
table.String("note", 255).Comment("shown to the user")
table.String("due_on", 10).Nullable()
table.Decimal("price").Places(2).Total(10)
table.Json("meta")
table.Unique("code").Name("idx_probes_code")
table.Index("status").Name("idx_probes_status")
})
```
- A pointer field, like `*string`, makes a column nullable. Every other column is not null, with or without a `not null` tag.
- Give a decimal its size with `precision` and `scale`. If you write `type:decimal(10,2)` instead, the size is lost.
- `uniqueIndex` works like `unique`, but the index is named `idx__` instead of `__unique`.
- `migration:` takes the name of a [schema builder](https://www.goravel.dev/database/migrations) method, so `migration:json` calls `table.Json`.
## The migration
A migration is a Go file with two methods: `Up` makes a change to the database, and `Down` undoes it. You could create the table by hand in Postgres, but then it would only exist on your machine. A migration lives in the project with the code, so every copy of TaskFlow gets the same table.
Goravel can write that file for you from the model. `-m User` tells it which model to read. If you run it now, it fails:
```bash
$ go run . artisan make:migration create_users_table -m User
ERROR create migration failed: model User not found in registered models
```
Goravel only knows the models you tell it about, because Go cannot look up a type by its name while the program runs. Register `User` in `bootstrap/app.go`:
```diff lang="go" title="bootstrap/app.go" collapse={17-20}
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().
WithMigrations(Migrations).
WithRouting(func() {
routes.Api()
routes.Web()
}).
WithProviders(Providers).
WithConfig(config.Boot).
+ WithCallback(func() {
+ facades.Schema().Extend(schema.Extension{
+ Models: []any{models.User{}},
+ })
+ }).
Create()
}
```
`WithCallback` takes a function that runs during startup, after the facades are ready. Inside it, `Extend` adds `User` to the models Goravel knows. Run the command again:
```bash
$ go run . artisan make:migration create_users_table -m User
SUCCESS Created Migration: create_users_table
SUCCESS Migration registered successfully
```
It writes the migration and adds it to the list in `bootstrap/migrations.go`. The file name, the struct and the signature include the time you ran the command, so yours will have a different number:
```diff lang="go" title="bootstrap/migrations.go" collapse={1-8}
package bootstrap
import (
"github.com/goravel/framework/contracts/database/schema"
"taskflow/database/migrations"
)
func Migrations() []schema.Migration {
return []schema.Migration{
&migrations.M20210101000001CreateJobsTable{},
+ &migrations.M20261022221906CreateUsersTable{},
}
}
```
This is what Goravel wrote:
```go title="database/migrations/20261022221906_create_users_table.go" t1:model=/"(created_at)"/ t1:model=/"(updated_at)"/ t1:model=/BigIncrements."(id)"/ t2:email=/Unique."(email)"/ t3:role=/Enum."(role)"/
package migrations
import (
"github.com/goravel/framework/contracts/database/schema"
"taskflow/app/facades"
)
type M20261022221906CreateUsersTable struct{}
// Signature The unique signature for the migration.
func (r *M20261022221906CreateUsersTable) Signature() string {
return "20261022221906_create_users_table"
}
// Up Run the migrations.
func (r *M20261022221906CreateUsersTable) Up() error {
if !facades.Schema().HasTable("users") {
return facades.Schema().Create("users", func(table schema.Blueprint) {
table.TimestampTz("created_at").Nullable()
table.TimestampTz("updated_at").Nullable()
table.BigIncrements("id")
table.String("name", 255)
table.String("email", 255)
table.String("password", 255)
table.Enum("role", []any{"manager", "member"}).Default("member")
table.Unique("email")
})
}
return nil
}
// Down Reverse the migrations.
func (r *M20261022221906CreateUsersTable) Down() error {
return facades.Schema().DropIfExists("users")
}
```
`Up` only creates the table if it is not there yet, and `Down` drops it.
Every column comes from the model:
- `created_at`, `updated_at` and `id` come from `orm.Model`.
- The unique email became `table.Unique("email")`.
- The role became `table.Enum`, with member as its default.
Only the two timestamps have `Nullable()`. Every other column is not null already, so the `not null` tags change nothing here. They only matter on a pointer field, which would otherwise be nullable. We keep them because they show that each column needs a value.
## Run the migration
If Postgres is not running, start it with `docker compose up -d`. Then run the migrations:
```bash
$ go run . artisan migrate
INFO Running migration
INFO Running: 20210101000002_create_jobs_table
INFO Running: 20261022221906_create_users_table
SUCCESS Migration success
```
The jobs migration runs too. It came with the Queue facade we picked when we [created the project](https://devchalk.com/goravel/01-installation-project-architecture#create-the-project), and it creates two tables, `jobs` and `failed_jobs`. Our queue runs on Redis, so it never uses `jobs`, but Goravel still records failed jobs in `failed_jobs`.
Its file and struct say `20210101000001`, but it reports itself as `20210101000002`. That is in the file Goravel generated, and it does no harm.
To see what has run:
```bash
$ go run . artisan migrate:status
Migration name .............................................. Batch / Status
20210101000002_create_jobs_table ................................... [1] Ran
20261022221906_create_users_table .................................. [1] Ran
```
Each `migrate` run is one batch, numbered one higher than the last. Both of ours ran in batch 1. To see the table itself:
```bash t1:model=/^ *(created_at) / t1:model=/^ *(updated_at) / t1:model=/^ *(id) autoincrement/ t2:email="users_email_unique" t3:role=/^ *(role) varchar/
$ go run . artisan db:table users
public.users ...............................................................
Columns .................................................................. 7
Size .............................................................. 0.023 MB
Column ................................................................ Type
created_at timestamptz, nullable ............... timestamp(0) with time zone
updated_at timestamptz, nullable ............... timestamp(0) with time zone
id autoincrement, int8 ............ nextval('users_id_seq'::regclass) bigint
name varchar ........................................ character varying(255)
email varchar ....................................... character varying(255)
password varchar .................................... character varying(255)
role varchar ............ 'member'::character varying character varying(255)
Index ......................................................................
users_email_unique email ..................................... btree, unique
users_pkey id ....................................... btree, unique, primary
```
> **Gotcha** `db:table` says `role` is `varchar`, not an enum. On Postgres, Goravel stores an enum as a varchar column with a check that only lets the allowed values in. Try a role that is not allowed:
>
> ```bash
> $ docker compose exec postgres psql -U goravel -d taskflow \
> -c "INSERT INTO users (name, email, password, role) VALUES ('Ana', 'ana@example.com', 'x', 'admin');"
> ERROR: new row for relation "users" violates check constraint "users_role_check"
> ```
## Rolling back a migration
When a migration is wrong, you roll it back, fix it, and run it again. `migrate:rollback` runs `Down` for the last batch:
```bash
$ go run . artisan migrate:rollback
INFO Rolling back migration
INFO Rolled back: 20261022221906_create_users_table
INFO Rolled back: 20210101000002_create_jobs_table
SUCCESS Migration rollback success
```
Both migrations were undone, not only the users one. A rollback undoes the whole last batch, and both migrations ran in batch 1. `migrate:status` now shows both as pending:
```bash
$ go run . artisan migrate:status
Migration name .............................................. Batch / Status
20210101000002_create_jobs_table ................................... Pending
20261022221906_create_users_table .................................. Pending
```
To undo only the last migration, add `--step=1`.
Run `migrate` again and both are back:
```bash
$ go run . artisan migrate
INFO Running migration
INFO Running: 20210101000002_create_jobs_table
INFO Running: 20261022221906_create_users_table
SUCCESS Migration success
```
TaskFlow now has a users table, created from its model. There is no table for tasks yet.
---
Previous: [03. Reading Request Data](https://devchalk.com/goravel/03-reading-request-data.md)
Next: [05. Relationships: Belongs To](https://devchalk.com/goravel/05-relationships-belongs-to.md)
Series: [Goravel](https://devchalk.com/goravel.md)
# Relationships: Belongs To
> Tasks tied to the users who made them and the users they are assigned to.
Goravel series, part 5 of 20. By Krishan Kumar. Published 2026-10-23, Updated 2026-10-24.
Source: https://devchalk.com/goravel/05-relationships-belongs-to
Code: https://github.com/krishankumar01/goravel-taskflow/tree/part-05
TaskFlow has a users table. A task is made by one user and can be assigned to another. In this part we create the tasks table with foreign keys to users, load a task with both of its users, and keep each user's password out of the JSON.
Start from the Models & Migrations code:
```bash
git clone https://github.com/krishankumar01/goravel-taskflow
cd goravel-taskflow
git checkout part-04
cp .env.example .env
go run . artisan key:generate
docker compose up -d
go run . artisan migrate
```
## The model
A task needs its own table, and like users it starts as a model:
```bash
$ go run . artisan make:model Task
SUCCESS Model created successfully
```
Then add the fields from the [database design](https://devchalk.com/goravel/01-installation-project-architecture#the-database-design):
```diff lang="go" title="app/models/task.go" t1:creator=/(CreatedBy) +uint/ t1:creator=/(Creator) +[*]User/ t2:assignee=/(AssignedTo) +[*]uint/ t2:assignee=/(Assignee) +[*]User/
package models
import (
"github.com/goravel/framework/database/orm"
+ "github.com/goravel/framework/support/carbon"
)
+type Status string
+
+const (
+ StatusPending Status = "pending"
+ StatusInProgress Status = "in-progress"
+ StatusDone Status = "done"
+)
+
type Task struct {
orm.Model
+ CreatedBy uint `gorm:"not null"`
+ Creator *User `gorm:"foreignKey:CreatedBy"`
+ AssignedTo *uint
+ Assignee *User `gorm:"foreignKey:AssignedTo"`
+ Title string `gorm:"size:255;not null"`
+ Description string `gorm:"type:text;not null"`
+ Status Status `gorm:"type:enum('pending','in-progress','done');default:'pending';not null"`
+ DueAt *carbon.DateTime
}
```
A task [belongs to](https://www.goravel.dev/orm/relationships#one-to-many-inverse-belongs-to) a user when the task row holds that user's id. Ours belongs to two users, so it has two pairs of fields:
- `CreatedBy` holds the id of the user who made the task, and `Creator` is that user.
- `AssignedTo` holds the id of the user who has to do it, and `Assignee` is that user.
- `Creator` and `Assignee` are pointers, so they stay `nil` until we load them.
- `AssignedTo` and `DueAt` are pointers too, because a new task may have nobody and no due date yet. As [Models & Migrations](https://devchalk.com/goravel/04-models-migrations#the-model) showed, a pointer field gives a nullable column.
- `DueAt` is a `carbon.DateTime`, the same type `orm.Model` uses for its timestamps.
- `Status` has its own type with three values, like `Role` on the user.
The `foreignKey` tag tells GORM which field holds the user's id. Without it, GORM looks for a field named `CreatorID` (the relation name plus `ID`), finds none, and every load of the task fails with:
```
invalid field found for struct taskflow/app/models.Task's field Creator: define a valid foreign key for relations or implement the Valuer/Scanner interface
```
We use `CreatedBy` because the column in the [database design](https://devchalk.com/goravel/01-installation-project-architecture#the-database-design) is `created_by`.
## The migration
`make:migration -m` only reads the models you register, as [Models & Migrations](https://devchalk.com/goravel/04-models-migrations#the-migration) showed. Add `Task` next to `User`:
```diff lang="go" title="bootstrap/app.go" collapse={1-22}
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().
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: []any{models.User{}, models.Task{}},
})
}).
Create()
}
```
Then generate the migration from it:
```bash
$ go run . artisan make:migration create_tasks_table -m Task
SUCCESS Created Migration: create_tasks_table
SUCCESS Migration registered successfully
```
This is the migration it wrote. As in [Models & Migrations](https://devchalk.com/goravel/04-models-migrations#the-migration), yours has a different number:
```go title="database/migrations/20261023063944_create_tasks_table.go" collapse={1-16} collapse={30-38}
package migrations
import (
"github.com/goravel/framework/contracts/database/schema"
"taskflow/app/facades"
)
type M20261023063944CreateTasksTable struct{}
// Signature The unique signature for the migration.
func (r *M20261023063944CreateTasksTable) Signature() string {
return "20261023063944_create_tasks_table"
}
// Up Run the migrations.
func (r *M20261023063944CreateTasksTable) Up() error {
if !facades.Schema().HasTable("tasks") {
return facades.Schema().Create("tasks", func(table schema.Blueprint) {
table.TimestampTz("created_at").Nullable()
table.TimestampTz("updated_at").Nullable()
table.BigIncrements("id")
table.UnsignedBigInteger("created_by")
table.UnsignedBigInteger("assigned_to").Nullable()
table.String("title", 255)
table.Text("description")
table.Enum("status", []any{"pending", "in-progress", "done"}).Default("pending")
table.TimestampTz("due_at").Nullable()
})
}
return nil
}
// Down Reverse the migrations.
func (r *M20261023063944CreateTasksTable) Down() error {
return facades.Schema().DropIfExists("tasks")
}
```
Every column is there, but there are no foreign keys. `Creator` and `Assignee` are not columns, so `make:migration` skips them. And despite its name, the `foreignKey` tag adds nothing to the table. It only tells GORM how to load the user. To stop a task from pointing at a user who does not exist, the table needs real foreign keys.
Add the two foreign keys yourself:
```diff lang="go" title="database/migrations/20261023063944_create_tasks_table.go" collapse={1-16} collapse={38-41} t1:creator=/Foreign."(created_by)"/ t2:assignee=/Foreign."(assigned_to)"/
package migrations
import (
"github.com/goravel/framework/contracts/database/schema"
"taskflow/app/facades"
)
type M20261023063944CreateTasksTable struct{}
// Signature The unique signature for the migration.
func (r *M20261023063944CreateTasksTable) Signature() string {
return "20261023063944_create_tasks_table"
}
// Up Run the migrations.
func (r *M20261023063944CreateTasksTable) Up() error {
if !facades.Schema().HasTable("tasks") {
return facades.Schema().Create("tasks", func(table schema.Blueprint) {
table.TimestampTz("created_at").Nullable()
table.TimestampTz("updated_at").Nullable()
table.BigIncrements("id")
table.UnsignedBigInteger("created_by")
table.UnsignedBigInteger("assigned_to").Nullable()
table.String("title", 255)
table.Text("description")
table.Enum("status", []any{"pending", "in-progress", "done"}).Default("pending")
table.TimestampTz("due_at").Nullable()
+
+ table.Foreign("created_by").References("id").On("users")
+ table.Foreign("assigned_to").References("id").On("users").NullOnDelete()
})
}
return nil
}
// Down Reverse the migrations.
func (r *M20261023063944CreateTasksTable) Down() error {
return facades.Schema().DropIfExists("tasks")
}
```
- `created_by` must be the id of a user. Postgres also refuses to delete a user who still has tasks they made. That is what Postgres does when no rule is given, so the `created_by` line has none. We could tell it to delete their tasks with them instead, but then deleting a user would quietly delete their work.
- `assigned_to` must be the id of a user too, or empty. With `NullOnDelete`, when that user is deleted, the task stays and has nobody assigned.
## Run the migration
```bash
$ go run . artisan migrate
INFO Running migration
INFO Running: 20261023063944_create_tasks_table
SUCCESS Migration success
```
Only the new migration runs, in batch 2:
```bash
$ go run . artisan migrate:status
Migration name .............................................. Batch / Status
20210101000002_create_jobs_table ................................... [1] Ran
20261022221906_create_users_table .................................. [1] Ran
20261023063944_create_tasks_table .................................. [2] Ran
```
`db:table` lists the foreign keys at the end:
```bash t1:creator="tasks_created_by_foreign" t2:assignee="tasks_assigned_to_foreign" t2:assignee="set null" t1:creator=/no action [/] (no action)/
$ go run . artisan db:table tasks
public.tasks ...............................................................
Columns .................................................................. 9
Size .............................................................. 0.016 MB
Column ................................................................ Type
created_at timestamptz, nullable ............... timestamp(0) with time zone
updated_at timestamptz, nullable ............... timestamp(0) with time zone
id autoincrement, int8 ............ nextval('tasks_id_seq'::regclass) bigint
created_by int8 ..................................................... bigint
assigned_to int8, nullable .......................................... bigint
title varchar ....................................... character varying(255)
description text ...................................................... text
status varchar ......... 'pending'::character varying character varying(255)
due_at timestamptz, nullable ................... timestamp(0) with time zone
Index ......................................................................
tasks_pkey id ....................................... btree, unique, primary
Foreign Key .......................................... On Update / On Delete
tasks_assigned_to_foreign assigned_to references id on users no action / set null
tasks_created_by_foreign created_by references id on users no action / no action
```
- `set null` comes from `NullOnDelete`.
- `no action` is the default.
- `created_by` and `assigned_to` are `bigint`, not unsigned, because Postgres has no unsigned numbers.
## Test data
TaskFlow has no endpoint that saves tasks yet, so add two users and two tasks with `psql`:
```bash
$ docker compose exec postgres psql -U goravel -d taskflow \
-c "INSERT INTO users (name, email, password, role) VALUES ('Ana', 'ana@example.com', 'x', 'manager'), ('Ben', 'ben@example.com', 'x', 'member');"
INSERT 0 2
$ docker compose exec postgres psql -U goravel -d taskflow \
-c "INSERT INTO tasks (created_by, assigned_to, title, description, due_at) VALUES (1, 2, 'Ship v1', 'The first release', '2026-11-02 09:00:00+00'), (1, NULL, 'Write the docs', 'Nobody has it yet', NULL);"
INSERT 0 2
```
Ana made both tasks. Ship v1 is assigned to Ben, and Write the docs has nobody yet. The password `x` is only a placeholder until we build register and login.
Now check that the foreign keys work. First, add a task made by user 99, who does not exist:
```bash
$ docker compose exec postgres psql -U goravel -d taskflow \
-c "INSERT INTO tasks (created_by, title, description) VALUES (99, 'Ghost', 'No such user');"
ERROR: insert or update on table "tasks" violates foreign key constraint "tasks_created_by_foreign"
DETAIL: Key (created_by)=(99) is not present in table "users".
```
Then try to delete Ana, who still has tasks:
```bash
$ docker compose exec postgres psql -U goravel -d taskflow \
-c "DELETE FROM users WHERE id = 1;"
ERROR: update or delete on table "users" violates foreign key constraint "tasks_created_by_foreign" on table "tasks"
DETAIL: Key (id)=(1) is still referenced from table "tasks".
```
## Finding a row with Find
`Show`, the handler from [Reading Request Data](https://devchalk.com/goravel/03-reading-request-data#route-parameters), only returns the id. Make it read the task from Postgres:
```diff lang="go" title="app/http/controllers/task_controller.go" collapse={10-35} collapse={52-62}
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 {
- id := ctx.Request().RouteInt("id")
+ var task models.Task
+ err := facades.Orm().Query().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, map[string]any{
- "id": id,
- })
+ 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,
})
}
```
`Find` reads the row with that id into `task`. If the query itself fails, `Show` answers `500` with the error. Sending `err.Error()` to the client is fine while we build, but it can show table and column names. We replace it when we build the task endpoints.
In `requests.http`, change the show request to task 1:
```diff lang="http" title="requests.http" collapse={1-8}
### 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
+GET http://localhost:3000/api/v1/tasks/1
### List tasks
GET http://localhost:3000/api/v1/tasks?status=done&page=2&filters[priority]=high
### Hello
GET http://localhost:3000/hello
```
If `air` is not running, start it, as in [Routing & Responses](https://devchalk.com/goravel/02-routing-response#hot-reload-with-air). Then send Show task from `requests.http`. Here is the response, formatted:
```json title="GET /api/v1/tasks/1" t1:creator=/"(Creator)":/ t2:assignee=/"(Assignee)":/
{
"created_at": null,
"updated_at": null,
"id": 1,
"CreatedBy": 1,
"Creator": null,
"AssignedTo": 2,
"Assignee": null,
"Title": "Ship v1",
"Description": "The first release",
"Status": "pending",
"DueAt": "2026-11-02 09:00:00"
}
```
The ids are there, but `Creator` and `Assignee` are `null`. Goravel never loads a related model unless you ask for it. Unlike Laravel, reading `task.Creator` later does not fetch the user. It stays `nil`.
The timestamps are `null` too. Goravel fills them when it saves a row, and these rows came from `psql`.
Ask for a task that does not exist, like `/tasks/9`, and `Find` returns no error. You get `200 OK` and a task full of zeros. We turn that into a 404 when we build the task endpoints.
## Eager loading with With
`With` tells the query to load a relation along with the task. This is called [eager loading](https://www.goravel.dev/orm/relationships#eager-loading):
```diff lang="go" title="app/http/controllers/task_controller.go" collapse={1-35} collapse={52-62} t1:creator=/With."(Creator)"/ t2:assignee=/With."(Assignee)"/
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 {
var task models.Task
- err := facades.Orm().Query().Find(&task, ctx.Request().RouteInt("id"))
+ 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,
})
}
```
The name in `With("Creator")` is the field name, not the column name. GORM reads `CreatedBy` from the task and loads that user. Run the request again:
```json title="GET /api/v1/tasks/1" t1:creator=/"(Creator)":/ t2:assignee=/"(Assignee)":/
{
"created_at": null,
"updated_at": null,
"id": 1,
"CreatedBy": 1,
"Creator": {
"created_at": null,
"updated_at": null,
"id": 1,
"Name": "Ana",
"Email": "ana@example.com",
"Password": "x",
"Role": "manager"
},
"AssignedTo": 2,
"Assignee": {
"created_at": null,
"updated_at": null,
"id": 2,
"Name": "Ben",
"Email": "ben@example.com",
"Password": "x",
"Role": "member"
},
"Title": "Ship v1",
"Description": "The first release",
"Status": "pending",
"DueAt": "2026-11-02 09:00:00"
}
```
Both users are there, and so are their passwords. Here they are `x`, but once real password hashes are stored, anyone who asks for a task would get them.
The keys are mixed, too. `id` and `created_at` are lowercase, but `Title` and `CreatedBy` are not. `orm.Model` gives its fields JSON names, and our fields have none, so Go uses the field name as it is.
## JSON field names
A `json` tag sets the name a field gets in the JSON, and `json:"-"` leaves the field out. Add them to the user:
```diff lang="go" title="app/models/user.go" collapse={1-14}
package models
import (
"github.com/goravel/framework/database/orm"
)
type Role string
const (
RoleManager Role = "manager"
RoleMember Role = "member"
)
type User struct {
orm.Model
- Name string `gorm:"size:255;not null"`
- Email string `gorm:"size:255;unique;not null"`
- Password string `gorm:"size:255;not null"`
- Role Role `gorm:"type:enum('manager','member');default:'member';not null"`
+ Name string `gorm:"size:255;not null" json:"name"`
+ Email string `gorm:"size:255;unique;not null" json:"email"`
+ Password string `gorm:"size:255;not null" json:"-"`
+ Role Role `gorm:"type:enum('manager','member');default:'member';not null" json:"role"`
}
```
And to the task:
```diff lang="go" title="app/models/task.go" collapse={1-15}
package models
import (
"github.com/goravel/framework/database/orm"
"github.com/goravel/framework/support/carbon"
)
type Status string
const (
StatusPending Status = "pending"
StatusInProgress Status = "in-progress"
StatusDone Status = "done"
)
type Task struct {
orm.Model
- CreatedBy uint `gorm:"not null"`
- Creator *User `gorm:"foreignKey:CreatedBy"`
- AssignedTo *uint
- Assignee *User `gorm:"foreignKey:AssignedTo"`
- Title string `gorm:"size:255;not null"`
- Description string `gorm:"type:text;not null"`
- Status Status `gorm:"type:enum('pending','in-progress','done');default:'pending';not null"`
- DueAt *carbon.DateTime
+ CreatedBy uint `gorm:"not null" json:"created_by"`
+ Creator *User `gorm:"foreignKey:CreatedBy" json:"creator"`
+ AssignedTo *uint `json:"assigned_to"`
+ Assignee *User `gorm:"foreignKey:AssignedTo" json:"assignee"`
+ Title string `gorm:"size:255;not null" json:"title"`
+ Description string `gorm:"type:text;not null" json:"description"`
+ Status Status `gorm:"type:enum('pending','in-progress','done');default:'pending';not null" json:"status"`
+ DueAt *carbon.DateTime `json:"due_at"`
}
```
The `gorm` tags stay as they were, so the table does not change. Run the request again:
```json title="GET /api/v1/tasks/1" t1:creator=/"(creator)":/ t2:assignee=/"(assignee)":/
{
"created_at": null,
"updated_at": null,
"id": 1,
"created_by": 1,
"creator": {
"created_at": null,
"updated_at": null,
"id": 1,
"name": "Ana",
"email": "ana@example.com",
"role": "manager"
},
"assigned_to": 2,
"assignee": {
"created_at": null,
"updated_at": null,
"id": 2,
"name": "Ben",
"email": "ben@example.com",
"role": "member"
},
"title": "Ship v1",
"description": "The first release",
"status": "pending",
"due_at": "2026-11-02 09:00:00"
}
```
Every key is lowercase, and the passwords are gone.
## The query log
`APP_DEBUG` is `true` in your `.env`, so Goravel writes every query it runs to a log file named after the day. Watch it in a second terminal, then send the request again:
```bash wrap t1:creator=/"users"."id" = (1)/ t2:assignee=/"users"."id" = (2)/
$ tail -f storage/logs/goravel-$(date +%F).log
[2026-10-23 06:40:35.843] local.info: [1.498ms] [rows:1] SELECT * FROM "users" WHERE "users"."id" = 2
[2026-10-23 06:40:35.844] local.info: [0.726ms] [rows:1] SELECT * FROM "users" WHERE "users"."id" = 1
[2026-10-23 06:40:35.844] local.info: [7.740ms] [rows:1] SELECT * FROM "tasks" WHERE "tasks"."id" = 1
```
Three queries: one for the task, and one for each `With`. The task query runs first, but its line comes last, because Goravel writes a line when a query finishes and the task query waits for its users.
Ask for task 2, which has nobody assigned:
```json title="GET /api/v1/tasks/2" t1:creator=/"(creator)":/
{
"created_at": null,
"updated_at": null,
"id": 2,
"created_by": 1,
"creator": {
"created_at": null,
"updated_at": null,
"id": 1,
"name": "Ana",
"email": "ana@example.com",
"role": "manager"
},
"assigned_to": null,
"assignee": null,
"title": "Write the docs",
"description": "Nobody has it yet",
"status": "pending",
"due_at": null
}
```
The log gets two new lines:
```text wrap
[2026-10-23 06:40:36.885] local.info: [0.475ms] [rows:1] SELECT * FROM "users" WHERE "users"."id" = 1
[2026-10-23 06:40:36.885] local.info: [2.427ms] [rows:1] SELECT * FROM "tasks" WHERE "tasks"."id" = 2
```
Two queries this time. `With` only runs a query when there is an id to look up.
A task now loads with both of its users, and no password leaves the API. A user can't list their own tasks yet.
---
Previous: [04. Models & Migrations](https://devchalk.com/goravel/04-models-migrations.md)
Next: [06. Relationships: Has Many](https://devchalk.com/goravel/06-relationships-has-many.md)
Series: [Goravel](https://devchalk.com/goravel.md)
# Relationships: Has Many
> Users loaded with the tasks they made and the tasks assigned to them.
Goravel series, part 6 of 20. By Krishan Kumar. Published 2026-10-23, Updated 2026-10-25.
Source: https://devchalk.com/goravel/06-relationships-has-many
Code: https://github.com/krishankumar01/goravel-taskflow/tree/part-06
In [Relationships: Belongs To](https://devchalk.com/goravel/05-relationships-belongs-to), each task loaded the user who made it and the user it is assigned to. In this part we go the other way, from a user to their tasks, and use the query log to find and fix the N+1 problem.
Start from the Relationships: Belongs To code:
```bash
git clone https://github.com/krishankumar01/goravel-taskflow
cd goravel-taskflow
git checkout part-05
cp .env.example .env
go run . artisan key:generate
docker compose up -d
go run . artisan migrate
docker compose exec postgres psql -U goravel -d taskflow \
-c "INSERT INTO users (name, email, password, role) VALUES ('Ana', 'ana@example.com', 'x', 'manager'), ('Ben', 'ben@example.com', 'x', 'member');" \
-c "INSERT INTO tasks (created_by, assigned_to, title, description, due_at) VALUES (1, 2, 'Ship v1', 'The first release', '2026-11-02 09:00:00+00'), (1, NULL, 'Write the docs', 'Nobody has it yet', NULL);"
```
The three tasks you add below get ids 3 to 5, not 4 to 6.
## Has many relationships
A task belongs to a user because the task row holds the user's id. From the user's side, that same link is called [has many](https://www.goravel.dev/orm/relationships#one-to-many), because one user can have many tasks. A user has two such lists, the tasks they made and the tasks assigned to them. Add both:
```diff lang="go" title="app/models/user.go" collapse={1-13} t2:created=/(CreatedTasks) +/ t1:assigned=/(AssignedTasks) +/
package models
import (
"github.com/goravel/framework/database/orm"
)
type Role string
const (
RoleManager Role = "manager"
RoleMember Role = "member"
)
type User struct {
orm.Model
Name string `gorm:"size:255;not null" json:"name"`
Email string `gorm:"size:255;unique;not null" json:"email"`
Password string `gorm:"size:255;not null" json:"-"`
Role Role `gorm:"type:enum('manager','member');default:'member';not null" json:"role"`
+
+ CreatedTasks []Task `gorm:"foreignKey:CreatedBy" json:"created_tasks"`
+ AssignedTasks []Task `gorm:"foreignKey:AssignedTo" json:"assigned_tasks"`
}
```
- `CreatedTasks` holds the tasks the user made. Its `foreignKey:CreatedBy` names the field on `Task` that holds this user's id.
- `AssignedTasks` holds the tasks assigned to the user, linked by `AssignedTo`.
- Without `foreignKey`, GORM looks for a `UserID` field on `Task`. There is none, so every query that reads users fails, even `GET /tasks/1`.
- They are slices, not pointers like `Creator`, because a user can have many tasks.
There is no migration this time. The ids already live in the tasks table, so the users table needs nothing new.
## Test data
Right now there are two tasks, and only one of them is assigned to anyone, so a list of users would not show much. Add a third user, Cleo, and three more tasks, all made by Ana. One of the tasks assigned to Ben is done. We use it later, when we leave done tasks out:
```bash
$ docker compose exec postgres psql -U goravel -d taskflow \
-c "INSERT INTO users (name, email, password, role) VALUES ('Cleo', 'cleo@example.com', 'x', 'member');"
INSERT 0 1
$ docker compose exec postgres psql -U goravel -d taskflow \
-c "INSERT INTO tasks (created_by, assigned_to, title, description, status) VALUES (1, 2, 'Fix the login bug', 'Users get logged out', 'in-progress'), (1, 3, 'Review the API', 'Check every endpoint', 'pending'), (1, 2, 'Plan the next release', 'Pick what goes in', 'done');"
INSERT 0 3
```
Three tasks are now assigned to Ben, and one to Cleo.
## Loading a has many relation
The project's `UserController` is still the sample it came with. Add a `Show` method to it that loads a user with both lists:
```diff lang="go" title="app/http/controllers/user_controller.go" t2:created=/With."(CreatedTasks)"/ t1:assigned=/With."(AssignedTasks)"/
package controllers
import (
+ "taskflow/app/facades"
+ "taskflow/app/models"
+
"github.com/goravel/framework/contracts/http"
)
-type UserController struct {}
+type UserController struct{}
func NewUserController() *UserController {
return &UserController{}
}
func (r *UserController) Index(ctx http.Context) http.Response {
return ctx.Response().Success().Json(http.Json{
"Hello": "Goravel",
})
}
+
+func (r *UserController) Show(ctx http.Context) http.Response {
+ var user models.User
+ err := facades.Orm().Query().
+ With("CreatedTasks").
+ With("AssignedTasks").
+ Find(&user, ctx.Request().RouteInt("id"))
+ if err != nil {
+ return ctx.Response().Json(500, map[string]any{
+ "error": err.Error(),
+ })
+ }
+
+ return ctx.Response().Json(200, user)
+}
```
Then its route:
```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()
+ userController := controllers.NewUserController()
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)
+
+ router.Get("users/{id}", userController.Show)
})
}
```
Add a request for Ben, user 2:
```diff lang="http" title="requests.http" collapse={1-9}
### 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/1
### List tasks
GET http://localhost:3000/api/v1/tasks?status=done&page=2&filters[priority]=high
+### Show user
+GET http://localhost:3000/api/v1/users/2
+
### Hello
GET http://localhost:3000/hello
```
If `air` is not running, start it, as in [Routing & Responses](https://devchalk.com/goravel/02-routing-response#hot-reload-with-air). Then send Show user. Here is the response, formatted:
```json title="GET /api/v1/users/2" collapse={23-48} t2:created=/"(created_tasks)":/ t1:assigned=/"(assigned_tasks)":/
{
"created_at": null,
"updated_at": null,
"id": 2,
"name": "Ben",
"email": "ben@example.com",
"role": "member",
"created_tasks": [],
"assigned_tasks": [
{
"created_at": null,
"updated_at": null,
"id": 1,
"created_by": 1,
"creator": null,
"assigned_to": 2,
"assignee": null,
"title": "Ship v1",
"description": "The first release",
"status": "pending",
"due_at": "2026-11-02 09:00:00"
},
{
"created_at": null,
"updated_at": null,
"id": 4,
"created_by": 1,
"creator": null,
"assigned_to": 2,
"assignee": null,
"title": "Fix the login bug",
"description": "Users get logged out",
"status": "in-progress",
"due_at": null
},
{
"created_at": null,
"updated_at": null,
"id": 6,
"created_by": 1,
"creator": null,
"assigned_to": 2,
"assignee": null,
"title": "Plan the next release",
"description": "Pick what goes in",
"status": "done",
"due_at": null
}
]
}
```
Ben made no tasks, so `created_tasks` is an empty list. He has three in `assigned_tasks`. Inside each task, `creator` and `assignee` are `null`, because `Show` does not load them.
The [query log](https://devchalk.com/goravel/05-relationships-belongs-to#the-query-log) shows one query for the user and one for each list:
```text wrap t2:created=/"(created_by)" = 2/ t1:assigned=/"(assigned_to)" = 2/
[2026-10-23 07:49:41.261] local.info: [1.316ms] [rows:3] SELECT * FROM "tasks" WHERE "tasks"."assigned_to" = 2
[2026-10-23 07:49:41.262] local.info: [0.691ms] [rows:0] SELECT * FROM "tasks" WHERE "tasks"."created_by" = 2
[2026-10-23 07:49:41.262] local.info: [6.399ms] [rows:1] SELECT * FROM "users" WHERE "users"."id" = 2
```
## Loading relations for a list
A list of users is where loading relations starts to cost queries. Replace the sample's `Index` with one that lists every user, and leave out `With` for now, to see what an unloaded list looks like:
```diff lang="go" title="app/http/controllers/user_controller.go" collapse={1-15} collapse={31-44}
package controllers
import (
"taskflow/app/facades"
"taskflow/app/models"
"github.com/goravel/framework/contracts/http"
)
type UserController struct{}
func NewUserController() *UserController {
return &UserController{}
}
func (r *UserController) Index(ctx http.Context) http.Response {
- return ctx.Response().Success().Json(http.Json{
- "Hello": "Goravel",
- })
+ var users []models.User
+ err := facades.Orm().Query().Find(&users)
+ if err != nil {
+ return ctx.Response().Json(500, map[string]any{
+ "error": err.Error(),
+ })
+ }
+
+ return ctx.Response().Json(200, users)
}
func (r *UserController) Show(ctx http.Context) http.Response {
var user models.User
err := facades.Orm().Query().
With("CreatedTasks").
With("AssignedTasks").
Find(&user, ctx.Request().RouteInt("id"))
if err != nil {
return ctx.Response().Json(500, map[string]any{
"error": err.Error(),
})
}
return ctx.Response().Json(200, user)
}
```
Add its route:
```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()
userController := controllers.NewUserController()
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)
+ router.Get("users", userController.Index)
router.Get("users/{id}", userController.Show)
})
}
```
`Index` now answers `/api/v1/users`, so remove the sample's `/users` route from `web.go`:
```diff lang="go" title="routes/web.go" collapse={10-18}
package routes
import (
"github.com/goravel/framework/contracts/http"
"github.com/goravel/framework/support"
- "taskflow/app/http/controllers"
"taskflow/app/facades"
)
func Web() {
facades.Route().Get("/", func(ctx http.Context) http.Response {
return ctx.Response().View().Make("welcome.tmpl", map[string]any{
"version": support.Version,
})
})
facades.Route().Static("public", "./public")
- userController := controllers.NewUserController()
- facades.Route().Get("/users", userController.Index)
-
facades.Route().Get("hello", func(ctx http.Context) http.Response {
return ctx.Response().Json(200, facades.Route().Info("tasks.create"))
})
}
```
Add a request for it:
```diff lang="http" title="requests.http" collapse={1-9}
### 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/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 List users. Here is the response, formatted:
```json title="GET /api/v1/users"
[
{
"created_at": null,
"updated_at": null,
"id": 1,
"name": "Ana",
"email": "ana@example.com",
"role": "manager",
"created_tasks": null,
"assigned_tasks": null
},
{
"created_at": null,
"updated_at": null,
"id": 2,
"name": "Ben",
"email": "ben@example.com",
"role": "member",
"created_tasks": null,
"assigned_tasks": null
},
{
"created_at": null,
"updated_at": null,
"id": 3,
"name": "Cleo",
"email": "cleo@example.com",
"role": "member",
"created_tasks": null,
"assigned_tasks": null
}
]
```
Every list is `null`, which means it was not loaded. `[]`, like Ben's `created_tasks` above, means it was loaded and is empty. As with `Creator`, reading `user.AssignedTasks` later does not fetch it. If you forget `With`, you get `null` and no error.
### The N+1 problem
Goravel has `Load`, which loads a relation onto a model you already have. Calling it in a loop is a common first try. Load the assigned tasks, the work each user has to do:
```diff lang="go" title="app/http/controllers/user_controller.go" collapse={1-15} collapse={36-49}
package controllers
import (
"taskflow/app/facades"
"taskflow/app/models"
"github.com/goravel/framework/contracts/http"
)
type UserController struct{}
func NewUserController() *UserController {
return &UserController{}
}
func (r *UserController) Index(ctx http.Context) http.Response {
var users []models.User
err := facades.Orm().Query().Find(&users)
if err != nil {
return ctx.Response().Json(500, map[string]any{
"error": err.Error(),
})
}
+ for i := range users {
+ if err := facades.Orm().Query().Load(&users[i], "AssignedTasks"); err != nil {
+ return ctx.Response().Json(500, map[string]any{
+ "error": err.Error(),
+ })
+ }
+ }
+
return ctx.Response().Json(200, users)
}
func (r *UserController) Show(ctx http.Context) http.Response {
var user models.User
err := facades.Orm().Query().
With("CreatedTasks").
With("AssignedTasks").
Find(&user, ctx.Request().RouteInt("id"))
if err != nil {
return ctx.Response().Json(500, map[string]any{
"error": err.Error(),
})
}
return ctx.Response().Json(200, user)
}
```
Send List users again. It works, but look at the log:
```text wrap
[2026-10-23 07:50:15.892] local.info: [5.191ms] [rows:3] SELECT * FROM "users"
[2026-10-23 07:50:15.894] local.info: [1.312ms] [rows:0] SELECT * FROM "tasks" WHERE "tasks"."assigned_to" = 1
[2026-10-23 07:50:15.894] local.info: [2.469ms] [rows:1] SELECT * FROM "users" WHERE "users"."id" = 1
[2026-10-23 07:50:15.896] local.info: [0.738ms] [rows:3] SELECT * FROM "tasks" WHERE "tasks"."assigned_to" = 2
[2026-10-23 07:50:15.896] local.info: [1.474ms] [rows:1] SELECT * FROM "users" WHERE "users"."id" = 2
[2026-10-23 07:50:15.897] local.info: [0.528ms] [rows:1] SELECT * FROM "tasks" WHERE "tasks"."assigned_to" = 3
[2026-10-23 07:50:15.897] local.info: [1.150ms] [rows:1] SELECT * FROM "users" WHERE "users"."id" = 3
```
Seven queries for three users. This is called the N+1 problem. There is one query for the list, and then a separate load for each of the N users.
Here each user costs two queries, because `Load` reads the user again, together with their tasks. With 100 users, this would be 201 queries for one request.
### Eager loading a list
Take the loop out and use `With` on the list, as in [Relationships: Belongs To](https://devchalk.com/goravel/05-relationships-belongs-to#eager-loading-with-with):
```diff lang="go" title="app/http/controllers/user_controller.go" collapse={1-15} collapse={39-52} t1:assigned=/With."(AssignedTasks)"/
package controllers
import (
"taskflow/app/facades"
"taskflow/app/models"
"github.com/goravel/framework/contracts/http"
)
type UserController struct{}
func NewUserController() *UserController {
return &UserController{}
}
func (r *UserController) Index(ctx http.Context) http.Response {
var users []models.User
- err := facades.Orm().Query().Find(&users)
+ err := facades.Orm().Query().
+ With("AssignedTasks").
+ Find(&users)
if err != nil {
return ctx.Response().Json(500, map[string]any{
"error": err.Error(),
})
- }
-
- for i := range users {
- if err := facades.Orm().Query().Load(&users[i], "AssignedTasks"); err != nil {
- return ctx.Response().Json(500, map[string]any{
- "error": err.Error(),
- })
- }
}
return ctx.Response().Json(200, users)
}
func (r *UserController) Show(ctx http.Context) http.Response {
var user models.User
err := facades.Orm().Query().
With("CreatedTasks").
With("AssignedTasks").
Find(&user, ctx.Request().RouteInt("id"))
if err != nil {
return ctx.Response().Json(500, map[string]any{
"error": err.Error(),
})
}
return ctx.Response().Json(200, user)
}
```
Send List users again. Ana has no tasks, Ben has three and Cleo has one, the same as with the loop. The log has two lines:
```text wrap t1:assigned=/"(assigned_to)" IN/
[2026-10-23 07:50:29.602] local.info: [1.504ms] [rows:4] SELECT * FROM "tasks" WHERE "tasks"."assigned_to" IN (1,2,3)
[2026-10-23 07:50:29.602] local.info: [7.835ms] [rows:3] SELECT * FROM "users"
```
One query reads the users. The other reads the tasks of all of them at once, with `assigned_to IN (1,2,3)`, and GORM hands each task to its user. It stays two queries however many users there are.
## Filtered and nested loads
`With` can do more than load a whole relation. It can leave some rows out, and it can load relations of the loaded rows. A done task is no longer work to do, so leave done tasks out. Then load who made each task:
```diff lang="go" title="app/http/controllers/user_controller.go" collapse={11-16} collapse={35-48}
package controllers
import (
"taskflow/app/facades"
"taskflow/app/models"
+ "github.com/goravel/framework/contracts/database/orm"
"github.com/goravel/framework/contracts/http"
)
type UserController struct{}
func NewUserController() *UserController {
return &UserController{}
}
func (r *UserController) Index(ctx http.Context) http.Response {
var users []models.User
err := facades.Orm().Query().
- With("AssignedTasks").
+ With("AssignedTasks", func(query orm.Query) orm.Query {
+ return query.Where("status <> ?", models.StatusDone)
+ }).
+ With("AssignedTasks.Creator").
Find(&users)
if err != nil {
return ctx.Response().Json(500, map[string]any{
"error": err.Error(),
})
}
return ctx.Response().Json(200, users)
}
func (r *UserController) Show(ctx http.Context) http.Response {
var user models.User
err := facades.Orm().Query().
With("CreatedTasks").
With("AssignedTasks").
Find(&user, ctx.Request().RouteInt("id"))
if err != nil {
return ctx.Response().Json(500, map[string]any{
"error": err.Error(),
})
}
return ctx.Response().Json(200, user)
}
```
- The function given to `With` filters what it loads. Here it leaves out tasks whose status is done.
- `AssignedTasks.Creator` loads each task's creator too. The dot goes one relation deeper.
Send List users again. The done task, "Plan the next release", is gone, and each task has its creator.
```json title="GET /api/v1/users" collapse={43-64} collapse={67-99}
[
{
"created_at": null,
"updated_at": null,
"id": 1,
"name": "Ana",
"email": "ana@example.com",
"role": "manager",
"created_tasks": null,
"assigned_tasks": []
},
{
"created_at": null,
"updated_at": null,
"id": 2,
"name": "Ben",
"email": "ben@example.com",
"role": "member",
"created_tasks": null,
"assigned_tasks": [
{
"created_at": null,
"updated_at": null,
"id": 1,
"created_by": 1,
"creator": {
"created_at": null,
"updated_at": null,
"id": 1,
"name": "Ana",
"email": "ana@example.com",
"role": "manager",
"created_tasks": null,
"assigned_tasks": null
},
"assigned_to": 2,
"assignee": null,
"title": "Ship v1",
"description": "The first release",
"status": "pending",
"due_at": "2026-11-02 09:00:00"
},
{
"created_at": null,
"updated_at": null,
"id": 4,
"created_by": 1,
"creator": {
"created_at": null,
"updated_at": null,
"id": 1,
"name": "Ana",
"email": "ana@example.com",
"role": "manager",
"created_tasks": null,
"assigned_tasks": null
},
"assigned_to": 2,
"assignee": null,
"title": "Fix the login bug",
"description": "Users get logged out",
"status": "in-progress",
"due_at": null
}
]
},
{
"created_at": null,
"updated_at": null,
"id": 3,
"name": "Cleo",
"email": "cleo@example.com",
"role": "member",
"created_tasks": null,
"assigned_tasks": [
{
"created_at": null,
"updated_at": null,
"id": 5,
"created_by": 1,
"creator": {
"created_at": null,
"updated_at": null,
"id": 1,
"name": "Ana",
"email": "ana@example.com",
"role": "manager",
"created_tasks": null,
"assigned_tasks": null
},
"assigned_to": 3,
"assignee": null,
"title": "Review the API",
"description": "Check every endpoint",
"status": "pending",
"due_at": null
}
]
}
]
```
Three queries this time:
```text wrap
[2026-10-23 07:50:47.458] local.info: [0.760ms] [rows:1] SELECT * FROM "users" WHERE "users"."id" = 1
[2026-10-23 07:50:47.458] local.info: [4.612ms] [rows:3] SELECT * FROM "tasks" WHERE "tasks"."assigned_to" IN (1,2,3) AND status <> 'done'
[2026-10-23 07:50:47.458] local.info: [9.377ms] [rows:3] SELECT * FROM "users"
```
The filter is part of the tasks query. All the tasks were made by Ana, so one more query, for user 1, loads every creator. With more creators it is still one query, using `IN`.
Users now load with their open tasks and who made each one, in a fixed number of queries. The test rows still have to be typed in by hand with `psql`.
---
Previous: [05. Relationships: Belongs To](https://devchalk.com/goravel/05-relationships-belongs-to.md)
Next: [07. Factories & Seeding](https://devchalk.com/goravel/07-factories-seeding.md)
Series: [Goravel](https://devchalk.com/goravel.md)
# Factories & Seeding
> Fake users and tasks, seeded with one command.
Goravel series, part 7 of 20. By Krishan Kumar. Published 2026-10-23, Updated 2026-10-24.
Source: https://devchalk.com/goravel/07-factories-seeding
Code: https://github.com/krishankumar01/goravel-taskflow/tree/part-07
So far every row came from `psql`, typed by hand, and had to be typed again each time the tables were rebuilt. A factory describes one fake row, and a seeder uses factories to fill the database. With both, one command rebuilds the database and fills it with users and tasks.
Start from the Relationships: Has Many code:
```bash
git clone https://github.com/krishankumar01/goravel-taskflow
cd goravel-taskflow
git checkout part-06
cp .env.example .env
go run . artisan key:generate
docker compose up -d
go run . artisan migrate
```
## The user factory
Users come first, because every task needs one. Create a factory for them:
```bash
$ go run . artisan make:factory UserFactory
SUCCESS Factory created successfully
```
It creates `database/factories/user_factory.go` with an empty `Definition`. Fill it in, using [gofakeit](https://github.com/brianvoe/gofakeit) for the fake values:
```go title="database/factories/user_factory.go" t1:factory=/type (UserFactory) struct/
package factories
import (
"github.com/brianvoe/gofakeit/v7"
"taskflow/app/facades"
)
type UserFactory struct {
}
// Definition Define the model's default state.
func (f *UserFactory) Definition() map[string]any {
password, _ := facades.Hash().Make("password")
return map[string]any{
"Name": gofakeit.Name(),
"Email": gofakeit.Email(),
"Password": password,
"Role": "member",
}
}
```
- The keys are the model's field names, like `DueAt`, not the column names, like `due_at`.
- Every fake user gets the password `password`, hashed with the `Hash` facade. bcrypt is slow on purpose, which is fine for a few rows. [Feature Testing](https://devchalk.com/goravel/13-feature-testing#a-database-for-the-tests) lowers its cost for the tests, where the hashing adds up.
- The role is the plain string `"member"`, not `models.RoleMember`. The model will import this package, and Go does not let two packages import each other. When the task endpoints need these values too, we move them into a package of their own.
Then run:
```bash
$ go mod tidy
```
The model finds its factory through a `Factory` method:
```diff lang="go" title="app/models/user.go" collapse={10-16} t1:factory=/factories.(UserFactory)/
package models
import (
+ "github.com/goravel/framework/contracts/database/factory"
"github.com/goravel/framework/database/orm"
+
+ "taskflow/database/factories"
)
type Role string
const (
RoleManager Role = "manager"
RoleMember Role = "member"
)
type User struct {
orm.Model
Name string `gorm:"size:255;not null" json:"name"`
Email string `gorm:"size:255;unique;not null" json:"email"`
Password string `gorm:"size:255;not null" json:"-"`
Role Role `gorm:"type:enum('manager','member');default:'member';not null" json:"role"`
CreatedTasks []Task `gorm:"foreignKey:CreatedBy" json:"created_tasks"`
AssignedTasks []Task `gorm:"foreignKey:AssignedTo" json:"assigned_tasks"`
}
+
+func (u *User) Factory() factory.Factory {
+ return &factories.UserFactory{}
+}
```
## The task factory
Tasks get a factory the same way:
```bash
$ go run . artisan make:factory TaskFactory
SUCCESS Factory created successfully
```
Fill in its `Definition`:
```go title="database/factories/task_factory.go"
package factories
import (
"github.com/brianvoe/gofakeit/v7"
"github.com/goravel/framework/support/carbon"
)
type TaskFactory struct {
}
// Definition Define the model's default state.
func (f *TaskFactory) Definition() map[string]any {
title := gofakeit.RandomString([]string{
"Fix the login bug",
"Write the API docs",
"Review the pull request",
"Plan the next release",
"Update the dependencies",
"Add tests for tasks",
"Clean up old logs",
"Reply to support emails",
})
status := gofakeit.RandomString([]string{"pending", "in-progress", "done"})
due := carbon.Now().AddDays(gofakeit.Number(1, 30)).StartOfDay().SetHour(9)
return map[string]any{
"Title": title,
"Description": gofakeit.HackerPhrase(),
"Status": status,
"DueAt": carbon.NewDateTime(due),
}
}
```
- Due dates are 1 to 30 days from today, at 9:00.
- `Status` is a plain string too, for the same reason as the role.
- There is no `CreatedBy` or `AssignedTo`. Both need real users, so the seeder sets them.
Link the factory to the `Task` model:
```diff lang="go" title="app/models/task.go" collapse={11-17}
package models
import (
+ "github.com/goravel/framework/contracts/database/factory"
"github.com/goravel/framework/database/orm"
"github.com/goravel/framework/support/carbon"
+
+ "taskflow/database/factories"
)
type Status string
const (
StatusPending Status = "pending"
StatusInProgress Status = "in-progress"
StatusDone Status = "done"
)
type Task struct {
orm.Model
CreatedBy uint `gorm:"not null" json:"created_by"`
Creator *User `gorm:"foreignKey:CreatedBy" json:"creator"`
AssignedTo *uint `json:"assigned_to"`
Assignee *User `gorm:"foreignKey:AssignedTo" json:"assignee"`
Title string `gorm:"size:255;not null" json:"title"`
Description string `gorm:"type:text;not null" json:"description"`
Status Status `gorm:"type:enum('pending','in-progress','done');default:'pending';not null" json:"status"`
DueAt *carbon.DateTime `json:"due_at"`
}
+
+func (t *Task) Factory() factory.Factory {
+ return &factories.TaskFactory{}
+}
```
## The user and task seeders
Make two seeders, one for users and one for tasks. Keeping them apart lets you run one alone, for example to add more tasks without touching the users:
```bash
$ go run . artisan make:seeder UserSeeder
SUCCESS Seeder created successfully
SUCCESS Seeder registered successfully
$ go run . artisan make:seeder TaskSeeder
SUCCESS Seeder created successfully
SUCCESS Seeder registered successfully
```
Like `make:migration`, `make:seeder` registers what it creates. It lists the seeders in a new `bootstrap/seeders.go`:
```go title="bootstrap/seeders.go"
package bootstrap
import (
"github.com/goravel/framework/contracts/database/seeder"
"taskflow/database/seeders"
)
func Seeders() []seeder.Seeder {
return []seeder.Seeder{
&seeders.UserSeeder{},
&seeders.TaskSeeder{},
}
}
```
`db:seed` runs them in this order. You made `UserSeeder` first, so the users exist before the tasks. `make:seeder` also added `WithSeeders(Seeders)` to `bootstrap/app.go`:
```diff lang="go" title="bootstrap/app.go" collapse={3-13} collapse={18-28}
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().
+ 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()
}
```
Fill in `Run` in `database/seeders/user_seeder.go`:
```go title="database/seeders/user_seeder.go"
package seeders
import (
"taskflow/app/facades"
"taskflow/app/models"
)
type UserSeeder struct {
}
// Signature The name and signature of the seeder.
func (s *UserSeeder) Signature() string {
return "UserSeeder"
}
// Run executes the seeder logic.
func (s *UserSeeder) Run() error {
var manager models.User
err := facades.Orm().Factory().Create(&manager, map[string]any{
"Name": "Ana",
"Email": "ana@example.com",
"Role": models.RoleManager,
})
if err != nil {
return err
}
var members []models.User
return facades.Orm().Factory().Count(3).Create(&members)
}
```
- The first `Create` makes Ana, the manager.
- The map overrides what the factory would pick, so Ana always has the email `ana@example.com`.
- Her password is `password`, like every fake user's.
- `Count(3)` then makes three members with fake names.
`TaskSeeder` can run alone, so it reads the users back from the database instead of getting them from `UserSeeder`. Fill in `database/seeders/task_seeder.go`:
```go title="database/seeders/task_seeder.go" t2:seeder=/return "(TaskSeeder)"/
package seeders
import (
"taskflow/app/facades"
"taskflow/app/models"
)
type TaskSeeder struct {
}
// Signature The name and signature of the seeder.
func (s *TaskSeeder) Signature() string {
return "TaskSeeder"
}
// Run executes the seeder logic.
func (s *TaskSeeder) Run() error {
var manager models.User
err := facades.Orm().Query().Where("role", models.RoleManager).FirstOrFail(&manager)
if err != nil {
return err
}
var members []models.User
err = facades.Orm().Query().Where("role", models.RoleMember).Find(&members)
if err != nil {
return err
}
for _, member := range members {
var tasks []models.Task
err = facades.Orm().Factory().Count(2).Create(&tasks, map[string]any{
"CreatedBy": manager.ID,
"AssignedTo": &member.ID,
})
if err != nil {
return err
}
}
var unassigned models.Task
return facades.Orm().Factory().Create(&unassigned, map[string]any{
"CreatedBy": manager.ID,
})
}
```
- If there is no manager yet, `FirstOrFail` stops the seeder with `record not found`. `First` would hand back an empty user instead.
- `AssignedTo` takes `&member.ID` because the field is a pointer.
- One more task is made by Ana and assigned to nobody.
## Running seeders with db:seed
`db:seed` runs every registered seeder:
```bash
$ go run . artisan db:seed
ERROR fail to run seeder: ERROR: duplicate key value violates unique constraint "users_email_unique" (SQLSTATE 23505)
```
It fails because Ana is still in the table from the earlier parts, and emails must be unique. A seeder only adds rows. It does not clear the tables first. If you started from the code at the top of this part, your tables were empty and it worked.
## Resetting the database with migrate:fresh
`migrate:fresh` drops every table and runs all the migrations again, and `--seed` then runs the seeders. Run it either way, so your ids match the ones below:
```bash
$ go run . artisan migrate:fresh --seed
SUCCESS Dropped all tables successfully
INFO Running migration
INFO Running: 20210101000002_create_jobs_table
INFO Running: 20261022221906_create_users_table
INFO Running: 20261023063944_create_tasks_table
SUCCESS Migration success
SUCCESS Database seeding completed successfully.
SUCCESS Migration fresh success
```
The tables now hold the seeded rows:
```bash
$ docker compose exec postgres psql -U goravel -d taskflow \
-c "SELECT id, name, email, role, left(password, 7) AS hash FROM users" \
-c "SELECT id, assigned_to, title, status, due_at::date AS due FROM tasks"
id | name | email | role | hash
----+----------------+----------------------------+---------+---------
1 | Ana | ana@example.com | manager | $2a$12$
2 | Darion Whitney | francis.hansen@jensen.name | member | $2a$12$
3 | Joany Silva | cpowell@richards.org | member | $2a$12$
4 | Cleora Grady | ruby.murphy3@harris.io | member | $2a$12$
(4 rows)
id | assigned_to | title | status | due
----+-------------+-------------------------+-------------+------------
1 | 2 | Add tests for tasks | in-progress | 2026-11-06
2 | 2 | Write the API docs | done | 2026-11-05
3 | 3 | Update the dependencies | done | 2026-10-25
4 | 3 | Fix the login bug | pending | 2026-11-15
5 | 4 | Update the dependencies | in-progress | 2026-10-28
6 | 4 | Write the API docs | in-progress | 2026-11-02
7 | | Reply to support emails | in-progress | 2026-11-15
(7 rows)
```
Your names, emails and dates are different, because they are random. The passwords start with `$2a$12$`, the start of a bcrypt hash, so they are real hashes now, not `x`.
## Running one seeder
`--seeder` runs only the seeders you name, by the name their `Signature` returns. To get more tasks for the same users, run `TaskSeeder` alone:
```bash t2:seeder=/--seeder=(TaskSeeder)/
$ go run . artisan db:seed --seeder=TaskSeeder
SUCCESS Database seeding completed successfully.
$ docker compose exec postgres psql -U goravel -d taskflow -c "SELECT count(*) FROM tasks"
count
-------
14
(1 row)
```
The same users got seven more tasks. Running `UserSeeder` alone would fail on Ana's email, just like before. Run `migrate:fresh --seed` again whenever you want the starting data back.
One command now rebuilds the database and fills it with users and tasks. The API still takes whatever data a client sends.
---
Previous: [06. Relationships: Has Many](https://devchalk.com/goravel/06-relationships-has-many.md)
Next: [08. Validation with Form Requests](https://devchalk.com/goravel/08-validation-form-requests.md)
Series: [Goravel](https://devchalk.com/goravel.md)
# 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)
# Register & Login with JWT
> Accounts, hashed passwords, signed tokens.
Goravel series, part 9 of 20. By Krishan Kumar. Published 2026-10-24, Updated 2026-10-25.
Source: https://devchalk.com/goravel/09-register-login-jwt
Code: https://github.com/krishankumar01/goravel-taskflow/tree/part-09
TaskFlow has users, but nobody can sign up or log in. Its clients may be mobile apps or scripts, not only browsers, so instead of a session cookie, login returns a token the client sends in a header. In this part we register users with a hashed password, log them in, and read the signed token on later requests. Then we log out and refresh tokens.
Start from the Validation with Form Requests code:
```bash
git clone https://github.com/krishankumar01/goravel-taskflow
cd goravel-taskflow
git checkout part-08
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
```
## Registering
To sign up, a client sends a name, an email and a password. We check them before storing anything. The email must be valid and not taken yet, and the password long enough. A [form request](https://www.goravel.dev/the-basics/validation#form-request-validation) does that, as in [Validation with Form Requests](https://devchalk.com/goravel/08-validation-form-requests#form-requests):
```bash
$ go run . artisan make:request RegisterRequest
SUCCESS Request created successfully
```
Fill it in, and delete the methods it doesn't use:
```go title="app/http/requests/register_request.go" t1:unique=/(unique):users,email/ t2:lower=/"trim[|](lower)"/
package requests
import (
"github.com/goravel/framework/contracts/http"
)
type RegisterRequest struct {
Name string `form:"name" json:"name"`
Email string `form:"email" json:"email"`
Password string `form:"password" json:"password"`
}
func (r *RegisterRequest) Authorize(ctx http.Context) error {
return nil
}
func (r *RegisterRequest) Rules(ctx http.Context) map[string]any {
return map[string]any{
"name": "required|string|max:255",
"email": "bail|required|string|email|max:255|unique:users,email",
"password": "bail|required|string|min:8|max_bytes:72",
}
}
func (r *RegisterRequest) Filters(ctx http.Context) map[string]any {
return map[string]any{
"name": "trim",
"email": "trim|lower",
}
}
```
- `lower` turns `Ben@Example.com` into `ben@example.com`. Filters run before rules, and Postgres compares text exactly, so without it the same person could sign up twice.
- `unique` looks the email up in `users`. `bail` stops at the first rule that fails, so a malformed email never reaches Postgres.
- Two sign-ups at the same moment can both pass `unique`. The unique index from [Models & Migrations](https://devchalk.com/goravel/04-models-migrations) then stops the second one.
- There is no `role` field, so a client can't make itself a manager.
We never store the password itself, only a [bcrypt](https://www.goravel.dev/security/hashing) hash of it. bcrypt reads at most 72 bytes of a password and refuses longer ones with an error, so the password needs a limit in bytes. Goravel has no such rule, so we make one:
Why not max:72:
`max:72` counts characters, not bytes. An emoji is 4 bytes, so a password of 20 emoji passes `max:72` and then fails inside `Hash().Make` with a `500`.
```bash
$ go run . artisan make:rule MaxBytes
SUCCESS Rule created successfully
SUCCESS Rule registered successfully
```
```diff lang="go" title="app/rules/max_bytes.go" collapse={10-18}
package rules
import (
"context"
+ "fmt"
+ "strconv"
"github.com/goravel/framework/contracts/validation"
)
type MaxBytes struct {
}
// Signature The name of the rule.
func (receiver *MaxBytes) Signature() string {
return "max_bytes"
}
// Passes Determine if the validation rule passes.
func (receiver *MaxBytes) Passes(ctx context.Context, data validation.Data, val any, options ...any) bool {
- return true
+ value, ok := val.(string)
+ if !ok || len(options) == 0 {
+ return false
+ }
+
+ limit, err := strconv.Atoi(fmt.Sprint(options[0]))
+ if err != nil {
+ return false
+ }
+
+ return len(value) <= limit
}
// Message Get the validation error message.
func (receiver *MaxBytes) Message(ctx context.Context) string {
- return ""
+ return "The :attribute must not be longer than :option0 bytes."
}
```
- The `72` in `max_bytes:72` arrives as `options[0]`, and the message shows it as `:option0`.
- The message doesn't use `:value`, which would put the password in the response.
- `make:rule` also added the rule to `bootstrap/rules.go`.
Now the handler. Registering, logging in and the rest belong together, so they get a controller of their own:
```bash
$ go run . artisan make:controller AuthController
SUCCESS Controller created successfully
```
Replace its `Index` with `Register`:
```diff lang="go" title="app/http/controllers/auth_controller.go" collapse={11-19} t3:hash=/Hash..[.](Make)/
package controllers
import (
+ "taskflow/app/facades"
+ "taskflow/app/http/requests"
+ "taskflow/app/models"
+
"github.com/goravel/framework/contracts/http"
)
type AuthController struct {
// Dependent services
}
func NewAuthController() *AuthController {
return &AuthController{
// Inject services
}
}
-func (r *AuthController) Index(ctx http.Context) http.Response {
- return nil
+func (r *AuthController) Register(ctx http.Context) http.Response {
+ var request requests.RegisterRequest
+ validationErrors, err := ctx.Request().ValidateRequest(&request)
+ if err != nil {
+ return ctx.Response().Json(500, map[string]any{
+ "error": "Could not create the account.",
+ })
+ }
+ if validationErrors != nil {
+ return ctx.Response().Json(422, map[string]any{
+ "errors": validationErrors.All(),
+ })
+ }
+
+ password, err := facades.Hash().Make(request.Password)
+ if err != nil {
+ return ctx.Response().Json(500, map[string]any{
+ "error": "Could not create the account.",
+ })
+ }
+
+ user := models.User{
+ Name: request.Name,
+ Email: request.Email,
+ Password: password,
+ }
+ if err := facades.Orm().WithContext(ctx).Query().Create(&user); err != nil {
+ return ctx.Response().Json(500, map[string]any{
+ "error": "Could not create the account.",
+ })
+ }
+
+ return ctx.Response().Json(201, user)
}
```
- `Hash().Make` turns the password into a bcrypt hash, and only the hash is saved.
- `WithContext(ctx)` gives the query the request's context, so the query stops if the client goes away.
- When something fails on the server, the client gets a plain message, because `err.Error()` can show table names, column names or SQL.
The handlers from earlier parts have neither of the last two yet. They get both when we build the task endpoints.
```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() {
+ authController := controllers.NewAuthController()
taskController := controllers.NewTaskController()
userController := controllers.NewUserController()
facades.Route().Prefix("api/v1").Group(func(router route.Router) {
+ router.Post("register", authController.Register)
+
router.Post("tasks", taskController.Store).Name("tasks.create")
router.Get("tasks/{id}", taskController.Show)
router.Get("tasks", taskController.Index)
router.Get("users", userController.Index)
router.Get("users/{id}", userController.Show)
})
}
```
```diff lang="http" title="requests.http" collapse={15-43}
+### Register
+POST http://localhost:3000/api/v1/register
+Content-Type: application/json
+
+{
+ "name": "Ben",
+ "email": "ben@example.com",
+ "password": "secret123"
+}
+
### 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
```
Sign Ben up:
```bash
$ curl -X POST http://localhost:3000/api/v1/register \
-H "Content-Type: application/json" \
-d '{"name":"Ben","email":"ben@example.com","password":"secret123"}'
```
```json title="POST /api/v1/register"
{
"created_at": "2026-10-24 13:34:22",
"updated_at": "2026-10-24 13:34:22",
"id": 5,
"name": "Ben",
"email": "ben@example.com",
"role": "member",
"created_tasks": null,
"assigned_tasks": null
}
```
The answer is `201 Created`, without the password, because of its `json:"-"` tag from [Relationships: Belongs To](https://devchalk.com/goravel/05-relationships-belongs-to#json-field-names). The [query log](https://devchalk.com/goravel/05-relationships-belongs-to#the-query-log) shows the email check, then the row with the hash from `Hash().Make`. GORM filled in `member` from the model's `default` tag:
```text wrap t3:hash=/'([$]2a[$]12[$][^']+)'/
[2026-10-24 13:34:21.965] local.info: [6.707ms] [rows:1] SELECT count(*) FROM "users" WHERE "email" = 'ben@example.com'
[2026-10-24 13:34:22.148] local.info: [2.475ms] [rows:1] INSERT INTO "users" ("created_at","updated_at","name","email","password","role") VALUES ('2026-10-24 13:34:22.145','2026-10-24 13:34:22.145','Ben','ben@example.com','$2a$12$Q.2kAvGjrc25EDfZAv6FEeGnD5EuyLn4BRjkRWcswCwvQgSy.eH7y','member') RETURNING "id"
```
The same email in capitals is turned away by `unique`:
```bash
$ curl -X POST http://localhost:3000/api/v1/register \
-H "Content-Type: application/json" \
-d '{"name":"Ben","email":"Ben@Example.com","password":"secret123"}'
```
```json title="POST /api/v1/register" t1:unique=/"(unique)":/
{
"errors": {
"email": {
"unique": "The email has already been taken."
}
}
}
```
The query log shows why. `lower` made the email lowercase before `unique` looked:
```text wrap t2:lower=/'(ben@example.com)'/
[2026-10-24 13:34:22.162] local.info: [0.863ms] [rows:1] SELECT count(*) FROM "users" WHERE "email" = 'ben@example.com'
```
A `"role": "manager"` in the body is ignored, and Cara gets a member account:
```bash
$ curl -X POST http://localhost:3000/api/v1/register \
-H "Content-Type: application/json" \
-d '{"name":"Cara","email":"cara@example.com","password":"secret123","role":"manager"}'
```
```json title="POST /api/v1/register"
{
"created_at": "2026-10-24 13:34:22",
"updated_at": "2026-10-24 13:34:22",
"id": 6,
"name": "Cara",
"email": "cara@example.com",
"role": "member",
"created_tasks": null,
"assigned_tasks": null
}
```
## Logging in
Ben has an account. Now he needs a way to prove it's him on every request. When he logs in, the API gives him a [JWT](https://www.goravel.dev/security/authentication#generate-jwt-token), a token that says which user he is. It is signed, so any change to it shows. The signing needs a secret, so we set that up first.
### The JWT secret
The server signs tokens with `JWT_SECRET` from `.env`, which is empty in a fresh clone. Generate one:
```bash
$ go run . artisan jwt:secret
SUCCESS Jwt Secret set successfully
```
Anyone with the secret can sign a token for any user, so it stays in `.env`, which git ignores. Running it again prints `exist jwt secret` and leaves the secret alone. It only fills in an existing `JWT_SECRET=` line. If the line is missing, it still prints `SUCCESS` but writes nothing.
The signing is done by a guard. A guard decides how a request proves who it is. Ours, `user`, came with the project. It uses JWT, and its `orm` provider loads users from the database:
```go title="config/auth.go" collapse={1-17} collapse={19-29} t2:guard=/"(user)": map/
package config
import (
"taskflow/app/facades"
)
func init() {
config := facades.Config()
config.Add("auth", map[string]any{
// Authentication Defaults
//
// This option controls the default authentication "guard"
// reset options for your application. You may change these defaults
// as required, but they're a perfect start for most applications.
"defaults": map[string]any{
"guard": "user",
},
// Authentication Guards
//
// Next, you may define every authentication guard for your application.
// Of course, a great default configuration has been defined for you
// here which uses session storage and the Eloquent user provider.
//
// All authentication drivers have a user provider. This defines how the
// users are actually retrieved out of your database or other storage
// mechanisms used by this application to persist your user's data.
//
// Supported drivers: "jwt", "session"
"guards": map[string]any{
"user": map[string]any{
"driver": "jwt",
"provider": "user",
},
},
// Supported: "orm"
"providers": map[string]any{
"user": map[string]any{
"driver": "orm",
},
},
})
}
```
Two settings in `config/jwt.go` set how long tokens last. `JWT_TTL` is 60 minutes by default. `JWT_REFRESH_TTL` is two weeks, and [Refreshing a token](#refreshing-a-token) covers it.
> **Gotcha** Without `JWT_SECRET`, a correct login and every route that reads a token answer `500 Internal Server Error` with an empty body. The reason, `authentication secret is missing or required`, is only in `storage/logs`.
### The login handler
Logging in means finding the user by email, checking the password, and handing back a token. Like registering, it starts with a form request:
```bash
$ go run . artisan make:request LoginRequest
SUCCESS Request created successfully
```
```go title="app/http/requests/login_request.go"
package requests
import (
"github.com/goravel/framework/contracts/http"
)
type LoginRequest struct {
Email string `form:"email" json:"email"`
Password string `form:"password" json:"password"`
}
func (r *LoginRequest) Authorize(ctx http.Context) error {
return nil
}
func (r *LoginRequest) Rules(ctx http.Context) map[string]any {
return map[string]any{
"email": "required|string|email",
"password": "bail|required|string|max_bytes:72",
}
}
func (r *LoginRequest) Filters(ctx http.Context) map[string]any {
return map[string]any{
"email": "trim|lower",
}
}
```
```diff lang="go" title="app/http/controllers/auth_controller.go" collapse={25-68} t3:nouser=/(noUserHash)/
package controllers
import (
+ "sync"
+
"taskflow/app/facades"
"taskflow/app/http/requests"
"taskflow/app/models"
"github.com/goravel/framework/contracts/http"
)
+
+// noUserHash is a bcrypt hash of a password nobody has, made with the same cost as the
+// real ones. An unknown email is checked against it, so it takes as long to refuse as a
+// wrong password.
+var noUserHash = sync.OnceValue(func() string {
+ hash, err := facades.Hash().Make("no user has this password")
+ if err != nil {
+ panic(err)
+ }
+
+ return hash
+})
type AuthController struct {
// Dependent services
}
func NewAuthController() *AuthController {
return &AuthController{
// Inject services
}
}
func (r *AuthController) Register(ctx http.Context) http.Response {
var request requests.RegisterRequest
validationErrors, err := ctx.Request().ValidateRequest(&request)
if err != nil {
return ctx.Response().Json(500, map[string]any{
"error": "Could not create the account.",
})
}
if validationErrors != nil {
return ctx.Response().Json(422, map[string]any{
"errors": validationErrors.All(),
})
}
password, err := facades.Hash().Make(request.Password)
if err != nil {
return ctx.Response().Json(500, map[string]any{
"error": "Could not create the account.",
})
}
user := models.User{
Name: request.Name,
Email: request.Email,
Password: password,
}
if err := facades.Orm().WithContext(ctx).Query().Create(&user); err != nil {
return ctx.Response().Json(500, map[string]any{
"error": "Could not create the account.",
})
}
return ctx.Response().Json(201, user)
}
+
+func (r *AuthController) Login(ctx http.Context) http.Response {
+ var request requests.LoginRequest
+ validationErrors, err := ctx.Request().ValidateRequest(&request)
+ if err != nil {
+ return ctx.Response().Json(500, map[string]any{
+ "error": "Could not log in.",
+ })
+ }
+ if validationErrors != nil {
+ return ctx.Response().Json(422, map[string]any{
+ "errors": validationErrors.All(),
+ })
+ }
+
+ var user models.User
+ if err := facades.Orm().WithContext(ctx).Query().Where("email", request.Email).First(&user); err != nil {
+ return ctx.Response().Json(500, map[string]any{
+ "error": "Could not log in.",
+ })
+ }
+
+ hash := user.Password
+ if user.ID == 0 {
+ hash = noUserHash()
+ }
+ if !facades.Hash().Check(request.Password, hash) || user.ID == 0 {
+ return ctx.Response().Json(401, map[string]any{
+ "error": "The email or password is wrong.",
+ })
+ }
+
+ token, err := facades.Auth(ctx).Login(&user)
+ if err != nil {
+ return ctx.Response().Json(500, map[string]any{
+ "error": "Could not log in.",
+ })
+ }
+
+ return ctx.Response().Json(200, map[string]any{
+ "token": token,
+ })
+}
```
`Login` does four things, in this order:
1. The query finds the user by email. When nobody has it, `First` returns no error and `user.ID` stays `0`.
2. `Hash().Check` compares the password with the stored hash. It runs before the `user.ID` test, so bcrypt runs either way.
3. `user.ID == 0` then refuses an unknown email, even if someone sent the right password for `noUserHash`.
4. [`Auth().Login`](https://www.goravel.dev/security/authentication#generate-token-using-user) signs a token for the user.
A wrong password and an unknown email get the same `401`, so the login route doesn't tell anyone which emails have accounts. Register still does, with "already been taken", which is the usual trade-off for a sign-up form. The response time could still give it away. bcrypt is slow on purpose, so an unknown email that skipped it would answer noticeably faster. `noUserHash` makes both cases run bcrypt. It is made on first use, with the same cost as `rounds` in `config/hashing.go`.
`LoginRequest` has `max_bytes:72` too. bcrypt reads only the first 72 bytes when it compares, so without the rule, a 72-byte password with anything added would still log in.
bcrypt also slows down each guess, but nothing yet limits how many a script can send. A limit on the login route comes with Auth Middleware & Rate Limits.
```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() {
authController := controllers.NewAuthController()
taskController := controllers.NewTaskController()
userController := controllers.NewUserController()
facades.Route().Prefix("api/v1").Group(func(router route.Router) {
router.Post("register", authController.Register)
+ router.Post("login", authController.Login)
router.Post("tasks", taskController.Store).Name("tasks.create")
router.Get("tasks/{id}", taskController.Show)
router.Get("tasks", taskController.Index)
router.Get("users", userController.Index)
router.Get("users/{id}", userController.Show)
})
}
```
The login request saves the `token` it gets back, so the requests below it can send it. The script after `>` runs when the response arrives. That syntax is GoLand's [response handler](https://www.jetbrains.com/help/go/http-response-handling-api-reference.html).
```diff lang="http" title="requests.http" collapse={1-4} collapse={28-56} t1:token=/set."(token)"/
### Register
POST http://localhost:3000/api/v1/register
Content-Type: application/json
{
"name": "Ben",
"email": "ben@example.com",
"password": "secret123"
}
+
+### Log in
+POST http://localhost:3000/api/v1/login
+Content-Type: application/json
+
+{
+ "email": "ben@example.com",
+ "password": "secret123"
+}
+
+> {%
+ client.global.set("token", response.body.token);
+%}
### 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
```
In VS Code:
REST Client runs the same file. Put `# @name login` above the login request, and use `{{login.response.body.$.token}}` where the requests below it use `{{token}}`.
Log Ben in:
```bash
$ curl -X POST http://localhost:3000/api/v1/login \
-H "Content-Type: application/json" \
-d '{"email":"ben@example.com","password":"secret123"}'
```
```json title="POST /api/v1/login" wrap t1:token=/"(token)":/
{
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJrZXkiOiI1Iiwic3ViIjoidXNlciIsImV4cCI6MTc5Mjg1MjQ2NSwiaWF0IjoxNzkyODQ4ODY1fQ.A55FXego6oS5Usv9ae95rIN2J4vFqMR5W3oFz6AurEw"
}
```
With curl, paste your whole token into a variable for the next requests:
```bash
$ TOKEN=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9…
```
The server keeps no record of the login. It reads one row and writes nothing:
```text wrap
[2026-10-24 13:34:25.59] local.info: [8.000ms] [rows:1] SELECT * FROM "users" WHERE "email" = 'ben@example.com' ORDER BY "users"."id" LIMIT 1
```
A wrong password and an unknown email both get:
```json title="POST /api/v1/login"
{
"error": "The email or password is wrong."
}
```
Timing the two failures:
Both take as long, because both run bcrypt. These timings are from my machine. Yours will differ, but the two should match. The very first unknown email after a restart is slower once, while `noUserHash` is made:
```bash
$ curl -s -o /dev/null -w "%{http_code} %{time_total}s\n" \
-X POST http://localhost:3000/api/v1/login \
-H "Content-Type: application/json" -d '{"email":"ben@example.com","password":"wrong-one"}'
401 0.166280s
$ curl -s -o /dev/null -w "%{http_code} %{time_total}s\n" \
-X POST http://localhost:3000/api/v1/login \
-H "Content-Type: application/json" -d '{"email":"nobody@example.com","password":"wrong-one"}'
401 0.166348s
```
## What a token holds
Ben now has a token, so let's look inside it. A token is three parts joined by dots: a header, a payload and a signature. The first two are JSON in base64url, so anyone can read them. Paste your token below, and add your `JWT_SECRET` to check the signature. Use it only with a local secret, never a production one.
With JavaScript on, this box decodes a token in your browser and checks its signature with your JWT_SECRET. A token is three base64url parts joined by dots: a header, a payload anyone can read, and a signature made with the secret.
The example token is Ben's, signed with the secret `taskflow-demo-secret`. It has expired. Yours is valid for an hour after you log in. Its payload holds these claims:
- `key` is the user's id.
- `sub` is the guard, `user`. Goravel puts the guard name there, where most systems put the user id.
- `iat` and `exp` are when the token was issued and when it expires, in Unix seconds. A client can read `exp` to know when to refresh, without asking the server.
Nothing in the payload is secret. What protects it is the signature, made from the header, the payload and `JWT_SECRET`. Turn on "Change to 1" and the token claims to be user 1, Ana, the manager. The signature no longer matches, so the API refuses it.
## Reading the token
A token is only useful if the API reads it back. The simplest route that needs it answers "who am I?":
```diff lang="go" title="app/http/controllers/auth_controller.go" collapse={15-113} t1:header=/Header."(Authorization)"/
package controllers
import (
+ "errors"
"sync"
"taskflow/app/facades"
"taskflow/app/http/requests"
"taskflow/app/models"
"github.com/goravel/framework/contracts/http"
+ frameworkerrors "github.com/goravel/framework/errors"
)
// noUserHash is a bcrypt hash of a password nobody has, made with the same cost as the
// real ones. An unknown email is checked against it, so it takes as long to refuse as a
// wrong password.
var noUserHash = sync.OnceValue(func() string {
hash, err := facades.Hash().Make("no user has this password")
if err != nil {
panic(err)
}
return hash
})
type AuthController struct {
// Dependent services
}
func NewAuthController() *AuthController {
return &AuthController{
// Inject services
}
}
func (r *AuthController) Register(ctx http.Context) http.Response {
var request requests.RegisterRequest
validationErrors, err := ctx.Request().ValidateRequest(&request)
if err != nil {
return ctx.Response().Json(500, map[string]any{
"error": "Could not create the account.",
})
}
if validationErrors != nil {
return ctx.Response().Json(422, map[string]any{
"errors": validationErrors.All(),
})
}
password, err := facades.Hash().Make(request.Password)
if err != nil {
return ctx.Response().Json(500, map[string]any{
"error": "Could not create the account.",
})
}
user := models.User{
Name: request.Name,
Email: request.Email,
Password: password,
}
if err := facades.Orm().WithContext(ctx).Query().Create(&user); err != nil {
return ctx.Response().Json(500, map[string]any{
"error": "Could not create the account.",
})
}
return ctx.Response().Json(201, user)
}
func (r *AuthController) Login(ctx http.Context) http.Response {
var request requests.LoginRequest
validationErrors, err := ctx.Request().ValidateRequest(&request)
if err != nil {
return ctx.Response().Json(500, map[string]any{
"error": "Could not log in.",
})
}
if validationErrors != nil {
return ctx.Response().Json(422, map[string]any{
"errors": validationErrors.All(),
})
}
var user models.User
if err := facades.Orm().WithContext(ctx).Query().Where("email", request.Email).First(&user); err != nil {
return ctx.Response().Json(500, map[string]any{
"error": "Could not log in.",
})
}
hash := user.Password
if user.ID == 0 {
hash = noUserHash()
}
if !facades.Hash().Check(request.Password, hash) || user.ID == 0 {
return ctx.Response().Json(401, map[string]any{
"error": "The email or password is wrong.",
})
}
token, err := facades.Auth(ctx).Login(&user)
if err != nil {
return ctx.Response().Json(500, map[string]any{
"error": "Could not log in.",
})
}
return ctx.Response().Json(200, map[string]any{
"token": token,
})
}
+
+func (r *AuthController) Me(ctx http.Context) http.Response {
+ if _, err := facades.Auth(ctx).Parse(ctx.Request().Header("Authorization")); err != nil {
+ return ctx.Response().Json(401, map[string]any{
+ "error": err.Error(),
+ })
+ }
+
+ var user models.User
+ if err := facades.Auth(ctx).User(&user); err != nil {
+ if errors.Is(err, frameworkerrors.OrmRecordNotFound) {
+ return ctx.Response().Json(401, map[string]any{
+ "error": "The user no longer exists.",
+ })
+ }
+
+ return ctx.Response().Json(500, map[string]any{
+ "error": "Could not load the user.",
+ })
+ }
+
+ return ctx.Response().Json(200, user)
+}
```
- [`Parse`](https://www.goravel.dev/security/authentication#parse-token) takes the `Authorization` header and removes `Bearer ` itself. Its errors are fixed messages like `authentication token has expired`, so they are safe to send.
- `User` loads the user whose id is in `key`, with the request's context.
- If that user has been deleted, `Me` says so, and any other error stays on the server.
Every route that needs a logged-in user repeats these lines, until Auth Middleware & Rate Limits moves them in front of the routes.
```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() {
authController := controllers.NewAuthController()
taskController := controllers.NewTaskController()
userController := controllers.NewUserController()
facades.Route().Prefix("api/v1").Group(func(router route.Router) {
router.Post("register", authController.Register)
router.Post("login", authController.Login)
+ router.Get("me", authController.Me)
router.Post("tasks", taskController.Store).Name("tasks.create")
router.Get("tasks/{id}", taskController.Show)
router.Get("tasks", taskController.Index)
router.Get("users", userController.Index)
router.Get("users/{id}", userController.Show)
})
}
```
`{{token}}` is the token the login request saved:
```diff lang="http" title="requests.http" collapse={1-19} collapse={32-60} t1:header=/(Authorization): Bearer/
### Register
POST http://localhost:3000/api/v1/register
Content-Type: application/json
{
"name": "Ben",
"email": "ben@example.com",
"password": "secret123"
}
### Log in
POST http://localhost:3000/api/v1/login
Content-Type: application/json
{
"email": "ben@example.com",
"password": "secret123"
}
> {%
client.global.set("token", response.body.token);
%}
+
+### Me
+GET http://localhost:3000/api/v1/me
+Authorization: Bearer {{token}}
### 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
```
```bash t1:header=/"(Authorization): Bearer/
$ curl http://localhost:3000/api/v1/me -H "Authorization: Bearer $TOKEN"
```
```json title="GET /api/v1/me"
{
"created_at": "2026-10-24 13:34:22",
"updated_at": "2026-10-24 13:34:22",
"id": 5,
"name": "Ben",
"email": "ben@example.com",
"role": "member",
"created_tasks": null,
"assigned_tasks": null
}
```
The API trusts the signature and reads the user by the id in the token:
```text wrap t3:key=/= '(5)'/
[2026-10-24 13:35:11.682] local.info: [2.067ms] [rows:1] SELECT * FROM "users" WHERE "users"."id" = '5'
```
## Bad and expired tokens
No header and a changed token get the same `401`:
```bash
$ curl http://localhost:3000/api/v1/me
{"error":"authentication token is invalid"}
$ curl http://localhost:3000/api/v1/me -H "Authorization: Bearer ${TOKEN}x"
{"error":"authentication token is invalid"}
```
To see an expired token, give tokens a one-minute life. A variable set on the command line wins over `.env`. Stop `air`, start it again with `JWT_TTL=1`, log in, set `TOKEN`, and wait a minute:
```bash
$ JWT_TTL=1 air
```
```bash
$ curl http://localhost:3000/api/v1/me -H "Authorization: Bearer $TOKEN"
{"error":"authentication token has expired"}
```
Then stop it, start plain `air` again, log in, and set `TOKEN` to the new token.
## Logging out
Logging out sounds simple, but the server can't take back a token it has signed, and it stays valid until it expires. Logout is the one place the server has to remember something about a token. It [marks the token as disabled](https://www.goravel.dev/security/authentication#logout) in the cache, Redis here, and `Parse` checks that mark before anything else. Step through a whole session to see where each request goes:
*One session against TaskFlow, one message at a time.*
Register: the unique rule counts users with the email, the API hashes the password with bcrypt and inserts one row. Log in: the API selects the user by email, checks the password against the hash, and signs a token with JWT_SECRET; nothing is written to Postgres or Redis. /me: the API asks Redis whether the token was logged out, checks the signature, and loads the user by the id in the token. Log out: the API writes the token into Redis as disabled for 60 minutes. /me again: Redis has the token, so the API answers 401 before checking the signature.
`Logout` needs to know which token to disable, so it parses the header first:
```diff lang="go" title="app/http/controllers/auth_controller.go" collapse={1-136} t1:logout=/Auth.ctx.[.](Logout)[(]/
package controllers
import (
"errors"
"sync"
"taskflow/app/facades"
"taskflow/app/http/requests"
"taskflow/app/models"
"github.com/goravel/framework/contracts/http"
frameworkerrors "github.com/goravel/framework/errors"
)
// noUserHash is a bcrypt hash of a password nobody has, made with the same cost as the
// real ones. An unknown email is checked against it, so it takes as long to refuse as a
// wrong password.
var noUserHash = sync.OnceValue(func() string {
hash, err := facades.Hash().Make("no user has this password")
if err != nil {
panic(err)
}
return hash
})
type AuthController struct {
// Dependent services
}
func NewAuthController() *AuthController {
return &AuthController{
// Inject services
}
}
func (r *AuthController) Register(ctx http.Context) http.Response {
var request requests.RegisterRequest
validationErrors, err := ctx.Request().ValidateRequest(&request)
if err != nil {
return ctx.Response().Json(500, map[string]any{
"error": "Could not create the account.",
})
}
if validationErrors != nil {
return ctx.Response().Json(422, map[string]any{
"errors": validationErrors.All(),
})
}
password, err := facades.Hash().Make(request.Password)
if err != nil {
return ctx.Response().Json(500, map[string]any{
"error": "Could not create the account.",
})
}
user := models.User{
Name: request.Name,
Email: request.Email,
Password: password,
}
if err := facades.Orm().WithContext(ctx).Query().Create(&user); err != nil {
return ctx.Response().Json(500, map[string]any{
"error": "Could not create the account.",
})
}
return ctx.Response().Json(201, user)
}
func (r *AuthController) Login(ctx http.Context) http.Response {
var request requests.LoginRequest
validationErrors, err := ctx.Request().ValidateRequest(&request)
if err != nil {
return ctx.Response().Json(500, map[string]any{
"error": "Could not log in.",
})
}
if validationErrors != nil {
return ctx.Response().Json(422, map[string]any{
"errors": validationErrors.All(),
})
}
var user models.User
if err := facades.Orm().WithContext(ctx).Query().Where("email", request.Email).First(&user); err != nil {
return ctx.Response().Json(500, map[string]any{
"error": "Could not log in.",
})
}
hash := user.Password
if user.ID == 0 {
hash = noUserHash()
}
if !facades.Hash().Check(request.Password, hash) || user.ID == 0 {
return ctx.Response().Json(401, map[string]any{
"error": "The email or password is wrong.",
})
}
token, err := facades.Auth(ctx).Login(&user)
if err != nil {
return ctx.Response().Json(500, map[string]any{
"error": "Could not log in.",
})
}
return ctx.Response().Json(200, map[string]any{
"token": token,
})
}
func (r *AuthController) Me(ctx http.Context) http.Response {
if _, err := facades.Auth(ctx).Parse(ctx.Request().Header("Authorization")); err != nil {
return ctx.Response().Json(401, map[string]any{
"error": err.Error(),
})
}
var user models.User
if err := facades.Auth(ctx).User(&user); err != nil {
if errors.Is(err, frameworkerrors.OrmRecordNotFound) {
return ctx.Response().Json(401, map[string]any{
"error": "The user no longer exists.",
})
}
return ctx.Response().Json(500, map[string]any{
"error": "Could not load the user.",
})
}
return ctx.Response().Json(200, user)
}
+
+func (r *AuthController) Logout(ctx http.Context) http.Response {
+ if _, err := facades.Auth(ctx).Parse(ctx.Request().Header("Authorization")); err != nil {
+ return ctx.Response().Json(401, map[string]any{
+ "error": err.Error(),
+ })
+ }
+
+ if err := facades.Auth(ctx).Logout(); err != nil {
+ return ctx.Response().Json(500, map[string]any{
+ "error": "Could not log out.",
+ })
+ }
+
+ return ctx.Response().NoContent()
+}
```
```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() {
authController := controllers.NewAuthController()
taskController := controllers.NewTaskController()
userController := controllers.NewUserController()
facades.Route().Prefix("api/v1").Group(func(router route.Router) {
router.Post("register", authController.Register)
router.Post("login", authController.Login)
router.Get("me", authController.Me)
+ router.Post("logout", authController.Logout)
router.Post("tasks", taskController.Store).Name("tasks.create")
router.Get("tasks/{id}", taskController.Show)
router.Get("tasks", taskController.Index)
router.Get("users", userController.Index)
router.Get("users/{id}", userController.Show)
})
}
```
```diff lang="http" title="requests.http" collapse={1-23} collapse={36-64}
### Register
POST http://localhost:3000/api/v1/register
Content-Type: application/json
{
"name": "Ben",
"email": "ben@example.com",
"password": "secret123"
}
### Log in
POST http://localhost:3000/api/v1/login
Content-Type: application/json
{
"email": "ben@example.com",
"password": "secret123"
}
> {%
client.global.set("token", response.body.token);
%}
### Me
GET http://localhost:3000/api/v1/me
Authorization: Bearer {{token}}
+### Log out
+POST http://localhost:3000/api/v1/logout
+Authorization: Bearer {{token}}
+
### 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
```
Start `docker compose exec redis redis-cli MONITOR | grep jwt` in a second terminal, then log out:
```bash
$ curl -i -X POST http://localhost:3000/api/v1/logout -H "Authorization: Bearer $TOKEN"
HTTP/1.1 204 No Content
```
`MONITOR` shows the check, then the mark that `Logout` writes. The mark lasts `JWT_TTL`, 3600 seconds:
```text wrap t1:logout=/"(set)"/
1792848916.663187 [0 172.18.0.1:59692] "get" "Goravel_cache:jwt:disabled:"
1792848916.664079 [0 172.18.0.1:59692] "set" "Goravel_cache:jwt:disabled:" "1" "ex" "3600"
```
The same token is refused now:
```bash
$ curl http://localhost:3000/api/v1/me -H "Authorization: Bearer $TOKEN"
{"error":"authentication token has been disabled"}
```
## Refreshing a token
[`Refresh`](https://www.goravel.dev/security/authentication#refresh-token) swaps a token for a new one. Ben's token expires after an hour, and logging in again every hour would be a chore, so the client trades it in instead. It works on an expired token too, for up to `JWT_REFRESH_TTL` after it expires:
```diff lang="go" title="app/http/controllers/auth_controller.go" collapse={16-138} collapse={164-178} t1:expired=/auth[.](ErrorTokenExpired)/
package controllers
import (
"errors"
"sync"
"taskflow/app/facades"
"taskflow/app/http/requests"
"taskflow/app/models"
+ "github.com/goravel/framework/auth"
"github.com/goravel/framework/contracts/http"
frameworkerrors "github.com/goravel/framework/errors"
)
// noUserHash is a bcrypt hash of a password nobody has, made with the same cost as the
// real ones. An unknown email is checked against it, so it takes as long to refuse as a
// wrong password.
var noUserHash = sync.OnceValue(func() string {
hash, err := facades.Hash().Make("no user has this password")
if err != nil {
panic(err)
}
return hash
})
type AuthController struct {
// Dependent services
}
func NewAuthController() *AuthController {
return &AuthController{
// Inject services
}
}
func (r *AuthController) Register(ctx http.Context) http.Response {
var request requests.RegisterRequest
validationErrors, err := ctx.Request().ValidateRequest(&request)
if err != nil {
return ctx.Response().Json(500, map[string]any{
"error": "Could not create the account.",
})
}
if validationErrors != nil {
return ctx.Response().Json(422, map[string]any{
"errors": validationErrors.All(),
})
}
password, err := facades.Hash().Make(request.Password)
if err != nil {
return ctx.Response().Json(500, map[string]any{
"error": "Could not create the account.",
})
}
user := models.User{
Name: request.Name,
Email: request.Email,
Password: password,
}
if err := facades.Orm().WithContext(ctx).Query().Create(&user); err != nil {
return ctx.Response().Json(500, map[string]any{
"error": "Could not create the account.",
})
}
return ctx.Response().Json(201, user)
}
func (r *AuthController) Login(ctx http.Context) http.Response {
var request requests.LoginRequest
validationErrors, err := ctx.Request().ValidateRequest(&request)
if err != nil {
return ctx.Response().Json(500, map[string]any{
"error": "Could not log in.",
})
}
if validationErrors != nil {
return ctx.Response().Json(422, map[string]any{
"errors": validationErrors.All(),
})
}
var user models.User
if err := facades.Orm().WithContext(ctx).Query().Where("email", request.Email).First(&user); err != nil {
return ctx.Response().Json(500, map[string]any{
"error": "Could not log in.",
})
}
hash := user.Password
if user.ID == 0 {
hash = noUserHash()
}
if !facades.Hash().Check(request.Password, hash) || user.ID == 0 {
return ctx.Response().Json(401, map[string]any{
"error": "The email or password is wrong.",
})
}
token, err := facades.Auth(ctx).Login(&user)
if err != nil {
return ctx.Response().Json(500, map[string]any{
"error": "Could not log in.",
})
}
return ctx.Response().Json(200, map[string]any{
"token": token,
})
}
func (r *AuthController) Me(ctx http.Context) http.Response {
if _, err := facades.Auth(ctx).Parse(ctx.Request().Header("Authorization")); err != nil {
return ctx.Response().Json(401, map[string]any{
"error": err.Error(),
})
}
var user models.User
if err := facades.Auth(ctx).User(&user); err != nil {
if errors.Is(err, frameworkerrors.OrmRecordNotFound) {
return ctx.Response().Json(401, map[string]any{
"error": "The user no longer exists.",
})
}
return ctx.Response().Json(500, map[string]any{
"error": "Could not load the user.",
})
}
return ctx.Response().Json(200, user)
}
+func (r *AuthController) Refresh(ctx http.Context) http.Response {
+ _, err := facades.Auth(ctx).Parse(ctx.Request().Header("Authorization"))
+ if err != nil && !errors.Is(err, auth.ErrorTokenExpired) {
+ return ctx.Response().Json(401, map[string]any{
+ "error": err.Error(),
+ })
+ }
+
+ token, err := facades.Auth(ctx).Refresh()
+ if errors.Is(err, auth.ErrorRefreshTimeExceeded) {
+ return ctx.Response().Json(401, map[string]any{
+ "error": err.Error(),
+ })
+ }
+ if err != nil {
+ return ctx.Response().Json(500, map[string]any{
+ "error": "Could not refresh the token.",
+ })
+ }
+
+ return ctx.Response().Json(200, map[string]any{
+ "token": token,
+ })
+}
+
func (r *AuthController) Logout(ctx http.Context) http.Response {
if _, err := facades.Auth(ctx).Parse(ctx.Request().Header("Authorization")); err != nil {
return ctx.Response().Json(401, map[string]any{
"error": err.Error(),
})
}
if err := facades.Auth(ctx).Logout(); err != nil {
return ctx.Response().Json(500, map[string]any{
"error": "Could not log out.",
})
}
return ctx.Response().NoContent()
}
```
`Parse` returns `auth.ErrorTokenExpired` for an expired token, but still reads it, so `Refresh` knows whose token it is. `errors.Is` checks for that error, and any other error still stops the request. `Refresh` answers `401` when the refresh window has passed, and a plain `500` for anything else.
```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() {
authController := controllers.NewAuthController()
taskController := controllers.NewTaskController()
userController := controllers.NewUserController()
facades.Route().Prefix("api/v1").Group(func(router route.Router) {
router.Post("register", authController.Register)
router.Post("login", authController.Login)
router.Get("me", authController.Me)
+ router.Post("refresh", authController.Refresh)
router.Post("logout", authController.Logout)
router.Post("tasks", taskController.Store).Name("tasks.create")
router.Get("tasks/{id}", taskController.Show)
router.Get("tasks", taskController.Index)
router.Get("users", userController.Index)
router.Get("users/{id}", userController.Show)
})
}
```
The refresh request saves the new token, as login does:
```diff lang="http" title="requests.http" collapse={1-23} collapse={40-72}
### Register
POST http://localhost:3000/api/v1/register
Content-Type: application/json
{
"name": "Ben",
"email": "ben@example.com",
"password": "secret123"
}
### Log in
POST http://localhost:3000/api/v1/login
Content-Type: application/json
{
"email": "ben@example.com",
"password": "secret123"
}
> {%
client.global.set("token", response.body.token);
%}
### Me
GET http://localhost:3000/api/v1/me
Authorization: Bearer {{token}}
+### Refresh
+POST http://localhost:3000/api/v1/refresh
+Authorization: Bearer {{token}}
+
+> {%
+ client.global.set("token", response.body.token);
+%}
+
### Log out
POST http://localhost:3000/api/v1/logout
Authorization: Bearer {{token}}
### 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
```
Your last token is logged out, so log in again and set `TOKEN` first:
```bash
$ curl -X POST http://localhost:3000/api/v1/refresh -H "Authorization: Bearer $TOKEN"
```
The new token comes back, shortened here:
```json title="POST /api/v1/refresh" wrap
{
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9…"
}
```
An expired token refreshes too. Start `air` with `JWT_TTL=1` again, log in, set `TOKEN`, and wait a minute:
```bash t1:expired=/"(authentication token has expired)"/
$ curl http://localhost:3000/api/v1/me -H "Authorization: Bearer $TOKEN"
{"error":"authentication token has expired"}
$ curl -X POST http://localhost:3000/api/v1/refresh -H "Authorization: Bearer $TOKEN"
{"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9…"}
```
Then start plain `air` again.
TaskFlow now has accounts, and the token routes know who is calling. The task routes still answer anyone.
---
Previous: [08. Validation with Form Requests](https://devchalk.com/goravel/08-validation-form-requests.md)
Next: [10. Auth Middleware & Rate Limits](https://devchalk.com/goravel/10-auth-middleware.md)
Series: [Goravel](https://devchalk.com/goravel.md)
# Auth Middleware & Rate Limits
> Routes only signed-in users can call.
Goravel series, part 10 of 20. By Krishan Kumar. Published 2026-10-25.
Source: https://devchalk.com/goravel/10-auth-middleware
Code: https://github.com/krishankumar01/goravel-taskflow/tree/part-10
In [Register & Login with JWT](https://devchalk.com/goravel/09-register-login-jwt), the token routes learned who is calling, but the task and user routes still answer anyone. `Me` and `Logout` also start with the same lines to read the token. In this part we move that check into a middleware that guards every route that needs a login, hand the signed-in user to the handlers, and limit how fast anyone can guess a password.
Start from the Register & Login with JWT code:
```bash
git clone https://github.com/krishankumar01/goravel-taskflow
cd goravel-taskflow
git checkout part-09
cp .env.example .env
go run . artisan key:generate
go run . artisan jwt:secret
docker compose up -d
go run . artisan migrate:fresh --seed
go install github.com/air-verse/air@latest
air
```
## The auth middleware
A [middleware](https://www.goravel.dev/the-basics/middleware) is code that runs before the handler. It either passes the request on or answers it, so one login check can guard every route that needs it.
```bash
$ go run . artisan make:middleware Auth
SUCCESS Middleware created successfully
```
It writes `app/http/middleware/auth.go`:
```go title="app/http/middleware/auth.go"
package middleware
import (
"github.com/goravel/framework/contracts/http"
)
type Auth struct{}
func (m *Auth) Signature() string {
return "Auth"
}
func (m *Auth) Handle(ctx http.Context) {
ctx.Request().Next()
}
```
- `Signature` names the middleware.
- `Handle` runs for each request. `ctx.Request().Next()` passes the request on to the next middleware, or to the handler.
Fill in `Handle` with the `Parse` check from `Me`:
```diff lang="go" title="app/http/middleware/auth.go" collapse={9-14} t2:parse=/Auth.ctx.[.](Parse)/ t1:abort=/[.](Abort)[(]/
package middleware
import (
+ "taskflow/app/facades"
+
"github.com/goravel/framework/contracts/http"
)
type Auth struct{}
func (m *Auth) Signature() string {
return "Auth"
}
func (m *Auth) Handle(ctx http.Context) {
+ if _, err := facades.Auth(ctx).Parse(ctx.Request().Header("Authorization")); err != nil {
+ ctx.Response().Json(401, map[string]any{
+ "error": err.Error(),
+ }).Abort()
+ return
+ }
+
ctx.Request().Next()
}
```
- If the token is missing, changed, expired or logged out, the middleware answers `401` with the reason.
- `Abort` stops the request there, so the handler never runs.
## Protecting routes
Register and log in stay open, since nobody has a token yet. Refresh stays open too, because it must accept an expired token, which the middleware refuses. Every other API route goes into a group behind the middleware:
```diff lang="go" title="routes/api.go" collapse={10-15}
package routes
import (
"taskflow/app/facades"
"taskflow/app/http/controllers"
+ "taskflow/app/http/middleware"
"github.com/goravel/framework/contracts/route"
)
func Api() {
authController := controllers.NewAuthController()
taskController := controllers.NewTaskController()
userController := controllers.NewUserController()
facades.Route().Prefix("api/v1").Group(func(router route.Router) {
router.Post("register", authController.Register)
router.Post("login", authController.Login)
- router.Get("me", authController.Me)
router.Post("refresh", authController.Refresh)
- router.Post("logout", authController.Logout)
- router.Post("tasks", taskController.Store).Name("tasks.create")
- router.Get("tasks/{id}", taskController.Show)
- router.Get("tasks", taskController.Index)
+ router.Middleware(&middleware.Auth{}).Group(func(router route.Router) {
+ router.Get("me", authController.Me)
+ router.Post("logout", authController.Logout)
- router.Get("users", userController.Index)
- router.Get("users/{id}", userController.Show)
+ router.Post("tasks", taskController.Store).Name("tasks.create")
+ router.Get("tasks/{id}", taskController.Show)
+ router.Get("tasks", taskController.Index)
+
+ router.Get("users", userController.Index)
+ router.Get("users/{id}", userController.Show)
+ })
})
}
```
`router.Middleware(&middleware.Auth{})` returns a router whose routes all run `Auth` first.
Without a token, the task list is refused now:
```bash
$ curl -i http://localhost:3000/api/v1/tasks
HTTP/1.1 401 Unauthorized
Content-Type: application/json; charset=utf-8
Content-Length: 43
{"error":"authentication token is invalid"}
```
Log in as Ana from the seeders, whose password is `password`, and keep the token in `TOKEN` as in [Register & Login with JWT](https://devchalk.com/goravel/09-register-login-jwt#the-login-handler):
```bash
$ curl -X POST http://localhost:3000/api/v1/login \
-H "Content-Type: application/json" \
-d '{"email":"ana@example.com","password":"password"}'
{"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9…"}
$ TOKEN=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9…
```
With the token, the list answers:
```bash
$ curl http://localhost:3000/api/v1/tasks -H "Authorization: Bearer $TOKEN"
{"filters":{},"page":1,"status":""}
```
In `requests.http`, the task and user requests need the header too:
```diff lang="http" title="requests.http" collapse={1-39} collapse={45-49} collapse={55-60}
### Register
POST http://localhost:3000/api/v1/register
Content-Type: application/json
{
"name": "Ben",
"email": "ben@example.com",
"password": "secret123"
}
### Log in
POST http://localhost:3000/api/v1/login
Content-Type: application/json
{
"email": "ben@example.com",
"password": "secret123"
}
> {%
client.global.set("token", response.body.token);
%}
### Me
GET http://localhost:3000/api/v1/me
Authorization: Bearer {{token}}
### Refresh
POST http://localhost:3000/api/v1/refresh
Authorization: Bearer {{token}}
> {%
client.global.set("token", response.body.token);
%}
### Log out
POST http://localhost:3000/api/v1/logout
Authorization: Bearer {{token}}
### Create task
POST http://localhost:3000/api/v1/tasks
+Authorization: Bearer {{token}}
Content-Type: application/json
{
"title": "Ship v1",
"description": "The first release"
}
### Create task with bad data
POST http://localhost:3000/api/v1/tasks
+Authorization: Bearer {{token}}
Content-Type: application/json
{
"title": " ",
"assigned_to": 99,
"due_at": "2020-01-01"
}
### Show task
GET http://localhost:3000/api/v1/tasks/1
+Authorization: Bearer {{token}}
### List tasks
GET http://localhost:3000/api/v1/tasks?status=done&page=2&filters[priority]=high
+Authorization: Bearer {{token}}
### List users
GET http://localhost:3000/api/v1/users
+Authorization: Bearer {{token}}
### Show user
GET http://localhost:3000/api/v1/users/2
+Authorization: Bearer {{token}}
### Hello
GET http://localhost:3000/hello
```
Middleware on every route:
`router.Middleware` puts a middleware on some routes. A middleware for every route, like one that logs each request, goes in `bootstrap/app.go` with `WithMiddleware`. See [global middleware](https://www.goravel.dev/the-basics/middleware#global-middleware).
Laravel also has a `guest` middleware, which keeps logged-in users away from the login page. An API with tokens has no such pages, so TaskFlow doesn't need one.
## One place to check the token
The middleware has parsed the token by the time `Me` and `Logout` run, so they can drop their own `Parse` lines. `Refresh` keeps its own:
```diff lang="go" title="app/http/controllers/auth_controller.go" collapse={1-115} collapse={123-163} t2:parse=/Auth.ctx.[.](Parse)/
package controllers
import (
"errors"
"sync"
"taskflow/app/facades"
"taskflow/app/http/requests"
"taskflow/app/models"
"github.com/goravel/framework/auth"
"github.com/goravel/framework/contracts/http"
frameworkerrors "github.com/goravel/framework/errors"
)
// noUserHash is a bcrypt hash of a password nobody has, made with the same cost as the
// real ones. An unknown email is checked against it, so it takes as long to refuse as a
// wrong password.
var noUserHash = sync.OnceValue(func() string {
hash, err := facades.Hash().Make("no user has this password")
if err != nil {
panic(err)
}
return hash
})
type AuthController struct {
// Dependent services
}
func NewAuthController() *AuthController {
return &AuthController{
// Inject services
}
}
func (r *AuthController) Register(ctx http.Context) http.Response {
var request requests.RegisterRequest
validationErrors, err := ctx.Request().ValidateRequest(&request)
if err != nil {
return ctx.Response().Json(500, map[string]any{
"error": "Could not create the account.",
})
}
if validationErrors != nil {
return ctx.Response().Json(422, map[string]any{
"errors": validationErrors.All(),
})
}
password, err := facades.Hash().Make(request.Password)
if err != nil {
return ctx.Response().Json(500, map[string]any{
"error": "Could not create the account.",
})
}
user := models.User{
Name: request.Name,
Email: request.Email,
Password: password,
}
if err := facades.Orm().WithContext(ctx).Query().Create(&user); err != nil {
return ctx.Response().Json(500, map[string]any{
"error": "Could not create the account.",
})
}
return ctx.Response().Json(201, user)
}
func (r *AuthController) Login(ctx http.Context) http.Response {
var request requests.LoginRequest
validationErrors, err := ctx.Request().ValidateRequest(&request)
if err != nil {
return ctx.Response().Json(500, map[string]any{
"error": "Could not log in.",
})
}
if validationErrors != nil {
return ctx.Response().Json(422, map[string]any{
"errors": validationErrors.All(),
})
}
var user models.User
if err := facades.Orm().WithContext(ctx).Query().Where("email", request.Email).First(&user); err != nil {
return ctx.Response().Json(500, map[string]any{
"error": "Could not log in.",
})
}
hash := user.Password
if user.ID == 0 {
hash = noUserHash()
}
if !facades.Hash().Check(request.Password, hash) || user.ID == 0 {
return ctx.Response().Json(401, map[string]any{
"error": "The email or password is wrong.",
})
}
token, err := facades.Auth(ctx).Login(&user)
if err != nil {
return ctx.Response().Json(500, map[string]any{
"error": "Could not log in.",
})
}
return ctx.Response().Json(200, map[string]any{
"token": token,
})
}
func (r *AuthController) Me(ctx http.Context) http.Response {
- if _, err := facades.Auth(ctx).Parse(ctx.Request().Header("Authorization")); err != nil {
- return ctx.Response().Json(401, map[string]any{
- "error": err.Error(),
- })
- }
-
var user models.User
if err := facades.Auth(ctx).User(&user); err != nil {
if errors.Is(err, frameworkerrors.OrmRecordNotFound) {
return ctx.Response().Json(401, map[string]any{
"error": "The user no longer exists.",
})
}
return ctx.Response().Json(500, map[string]any{
"error": "Could not load the user.",
})
}
return ctx.Response().Json(200, user)
}
func (r *AuthController) Refresh(ctx http.Context) http.Response {
_, err := facades.Auth(ctx).Parse(ctx.Request().Header("Authorization"))
if err != nil && !errors.Is(err, auth.ErrorTokenExpired) {
return ctx.Response().Json(401, map[string]any{
"error": err.Error(),
})
}
token, err := facades.Auth(ctx).Refresh()
if errors.Is(err, auth.ErrorRefreshTimeExceeded) {
return ctx.Response().Json(401, map[string]any{
"error": err.Error(),
})
}
if err != nil {
return ctx.Response().Json(500, map[string]any{
"error": "Could not refresh the token.",
})
}
return ctx.Response().Json(200, map[string]any{
"token": token,
})
}
func (r *AuthController) Logout(ctx http.Context) http.Response {
- if _, err := facades.Auth(ctx).Parse(ctx.Request().Header("Authorization")); err != nil {
- return ctx.Response().Json(401, map[string]any{
- "error": err.Error(),
- })
- }
-
if err := facades.Auth(ctx).Logout(); err != nil {
return ctx.Response().Json(500, map[string]any{
"error": "Could not log out.",
})
}
return ctx.Response().NoContent()
}
```
They still work, because `Parse` leaves the token's claims on the request for `User` and `Logout` to read:
```bash
$ curl http://localhost:3000/api/v1/me -H "Authorization: Bearer $TOKEN"
{"created_at":"2026-10-24 18:48:20","updated_at":"2026-10-24 18:48:20","id":1,"name":"Ana","email":"ana@example.com","role":"manager","created_tasks":null,"assigned_tasks":null}
$ curl -i -X POST http://localhost:3000/api/v1/logout -H "Authorization: Bearer $TOKEN"
HTTP/1.1 204 No Content
$ curl http://localhost:3000/api/v1/me -H "Authorization: Bearer $TOKEN"
{"error":"authentication token has been disabled"}
```
> **Gotcha** `Json` only builds the answer. `.Abort()` stops the request and sends it. Leave `Abort` out and the `401` is never sent, and the handler runs as if the middleware weren't there. With no token, the task list answers and `/me` fails with a `500`:
>
> ```bash t1:abort=/(200 OK)/
> $ curl -i http://localhost:3000/api/v1/tasks
> HTTP/1.1 200 OK
> Content-Type: application/json; charset=utf-8
> Content-Length: 35
>
> {"filters":{},"page":1,"status":""}
>
> $ curl -i http://localhost:3000/api/v1/me
> HTTP/1.1 500 Internal Server Error
> Content-Type: application/json; charset=utf-8
> Content-Length: 36
>
> {"error":"Could not load the user."}
> ```
>
> Nothing warns you, so every answer in a middleware needs `Abort`.
## A token for a deleted user
`Parse` checks the token's signature, its expiry and the logged-out list in Redis. It never asks Postgres, so a token keeps working after its user is gone. Register Dan, log him in, and delete him while his token is still valid:
```bash
$ curl -X POST http://localhost:3000/api/v1/register \
-H "Content-Type: application/json" \
-d '{"name":"Dan","email":"dan@example.com","password":"secret123"}'
{"created_at":"2026-10-24 18:48:29","updated_at":"2026-10-24 18:48:29","id":5,"name":"Dan","email":"dan@example.com","role":"member","created_tasks":null,"assigned_tasks":null}
$ curl -X POST http://localhost:3000/api/v1/login \
-H "Content-Type: application/json" \
-d '{"email":"dan@example.com","password":"secret123"}'
{"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9…"}
$ TOKEN=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9…
$ docker compose exec postgres psql -U goravel -d taskflow -c "DELETE FROM users WHERE email = 'dan@example.com'"
DELETE 1
```
The task list still answers him, and only `/me` notices, because it is the one handler that loads the user:
```bash
$ curl http://localhost:3000/api/v1/tasks -H "Authorization: Bearer $TOKEN"
{"filters":{},"page":1,"status":""}
$ curl http://localhost:3000/api/v1/me -H "Authorization: Bearer $TOKEN"
{"error":"The user no longer exists."}
```
Goravel's usual way is to call `facades.Auth(ctx).User(&user)` in the handlers that need the user. That only checks the routes whose handlers remember to call it, and each call runs its own query. Loading the user in the middleware checks every protected route, and the handlers get the user without asking again.
## Passing the user to handlers
`ctx.WithValue` attaches a value to the request, and `ctx.Value` reads it back by its key. A small package that owns the key means the user can only be set and read through its two functions:
```go title="app/http/authuser/authuser.go" t3:user=/func (Set|From)[(]/
package authuser
import (
"context"
"taskflow/app/models"
"github.com/goravel/framework/contracts/http"
)
type key struct{}
func Set(ctx http.Context, user *models.User) {
ctx.WithValue(key{}, user)
}
func From(ctx context.Context) *models.User {
user, _ := ctx.Value(key{}).(*models.User)
return user
}
```
- The key is `key{}`, a type only this package can make. A string like `"user"` could clash with other values on the request, the framework's included. Go's [context docs](https://pkg.go.dev/context#WithValue) advise the same.
- `Set` takes Goravel's `http.Context`. Its `WithValue` changes the request in place. Go's own `context.WithValue` returns a new context, which the handler would never see.
- `From` takes any `context.Context`, so code that only has a `context.Context`, such as a gate, can read the user too.
- `From` returns `nil` when no user was set. Only call it in a handler behind `Auth`.
In the middleware, load the user after `Parse` and store it:
```diff lang="go" title="app/http/middleware/auth.go" collapse={14-19} t3:user=/authuser[.](Set)[(]/
package middleware
import (
+ "errors"
+
"taskflow/app/facades"
+ "taskflow/app/http/authuser"
+ "taskflow/app/models"
"github.com/goravel/framework/contracts/http"
+ frameworkerrors "github.com/goravel/framework/errors"
)
type Auth struct{}
func (m *Auth) Signature() string {
return "Auth"
}
func (m *Auth) Handle(ctx http.Context) {
if _, err := facades.Auth(ctx).Parse(ctx.Request().Header("Authorization")); err != nil {
ctx.Response().Json(401, map[string]any{
"error": err.Error(),
}).Abort()
return
}
+ var user models.User
+ if err := facades.Auth(ctx).User(&user); err != nil {
+ if errors.Is(err, frameworkerrors.OrmRecordNotFound) {
+ ctx.Response().Json(401, map[string]any{
+ "error": "The user no longer exists.",
+ }).Abort()
+ return
+ }
+
+ ctx.Response().Json(500, map[string]any{
+ "error": "Could not load the user.",
+ }).Abort()
+ return
+ }
+
+ authuser.Set(ctx, &user)
ctx.Request().Next()
}
```
The two error answers come from `Me`, which becomes one line that reads the stored user with `From`:
```diff lang="go" title="app/http/controllers/auth_controller.go" collapse={17-116} collapse={135-168} t3:user=/authuser[.](From)[(]/
package controllers
import (
"errors"
"sync"
"taskflow/app/facades"
+ "taskflow/app/http/authuser"
"taskflow/app/http/requests"
"taskflow/app/models"
"github.com/goravel/framework/auth"
"github.com/goravel/framework/contracts/http"
- frameworkerrors "github.com/goravel/framework/errors"
)
// noUserHash is a bcrypt hash of a password nobody has, made with the same cost as the
// real ones. An unknown email is checked against it, so it takes as long to refuse as a
// wrong password.
var noUserHash = sync.OnceValue(func() string {
hash, err := facades.Hash().Make("no user has this password")
if err != nil {
panic(err)
}
return hash
})
type AuthController struct {
// Dependent services
}
func NewAuthController() *AuthController {
return &AuthController{
// Inject services
}
}
func (r *AuthController) Register(ctx http.Context) http.Response {
var request requests.RegisterRequest
validationErrors, err := ctx.Request().ValidateRequest(&request)
if err != nil {
return ctx.Response().Json(500, map[string]any{
"error": "Could not create the account.",
})
}
if validationErrors != nil {
return ctx.Response().Json(422, map[string]any{
"errors": validationErrors.All(),
})
}
password, err := facades.Hash().Make(request.Password)
if err != nil {
return ctx.Response().Json(500, map[string]any{
"error": "Could not create the account.",
})
}
user := models.User{
Name: request.Name,
Email: request.Email,
Password: password,
}
if err := facades.Orm().WithContext(ctx).Query().Create(&user); err != nil {
return ctx.Response().Json(500, map[string]any{
"error": "Could not create the account.",
})
}
return ctx.Response().Json(201, user)
}
func (r *AuthController) Login(ctx http.Context) http.Response {
var request requests.LoginRequest
validationErrors, err := ctx.Request().ValidateRequest(&request)
if err != nil {
return ctx.Response().Json(500, map[string]any{
"error": "Could not log in.",
})
}
if validationErrors != nil {
return ctx.Response().Json(422, map[string]any{
"errors": validationErrors.All(),
})
}
var user models.User
if err := facades.Orm().WithContext(ctx).Query().Where("email", request.Email).First(&user); err != nil {
return ctx.Response().Json(500, map[string]any{
"error": "Could not log in.",
})
}
hash := user.Password
if user.ID == 0 {
hash = noUserHash()
}
if !facades.Hash().Check(request.Password, hash) || user.ID == 0 {
return ctx.Response().Json(401, map[string]any{
"error": "The email or password is wrong.",
})
}
token, err := facades.Auth(ctx).Login(&user)
if err != nil {
return ctx.Response().Json(500, map[string]any{
"error": "Could not log in.",
})
}
return ctx.Response().Json(200, map[string]any{
"token": token,
})
}
func (r *AuthController) Me(ctx http.Context) http.Response {
- var user models.User
- if err := facades.Auth(ctx).User(&user); err != nil {
- if errors.Is(err, frameworkerrors.OrmRecordNotFound) {
- return ctx.Response().Json(401, map[string]any{
- "error": "The user no longer exists.",
- })
- }
-
- return ctx.Response().Json(500, map[string]any{
- "error": "Could not load the user.",
- })
- }
-
- return ctx.Response().Json(200, user)
+ return ctx.Response().Json(200, authuser.From(ctx))
}
func (r *AuthController) Refresh(ctx http.Context) http.Response {
_, err := facades.Auth(ctx).Parse(ctx.Request().Header("Authorization"))
if err != nil && !errors.Is(err, auth.ErrorTokenExpired) {
return ctx.Response().Json(401, map[string]any{
"error": err.Error(),
})
}
token, err := facades.Auth(ctx).Refresh()
if errors.Is(err, auth.ErrorRefreshTimeExceeded) {
return ctx.Response().Json(401, map[string]any{
"error": err.Error(),
})
}
if err != nil {
return ctx.Response().Json(500, map[string]any{
"error": "Could not refresh the token.",
})
}
return ctx.Response().Json(200, map[string]any{
"token": token,
})
}
func (r *AuthController) Logout(ctx http.Context) http.Response {
if err := facades.Auth(ctx).Logout(); err != nil {
return ctx.Response().Json(500, map[string]any{
"error": "Could not log out.",
})
}
return ctx.Response().NoContent()
}
```
Log in as Ana again and ask for `/me`:
```bash
$ curl http://localhost:3000/api/v1/me -H "Authorization: Bearer $TOKEN"
```
```json title="GET /api/v1/me"
{
"created_at": "2026-10-24 18:48:20",
"updated_at": "2026-10-24 18:48:20",
"id": 1,
"name": "Ana",
"email": "ana@example.com",
"role": "manager",
"created_tasks": null,
"assigned_tasks": null
}
```
The `/me` request adds one query to the [query log](https://devchalk.com/goravel/05-relationships-belongs-to#the-query-log), from the middleware:
```text wrap
[2026-10-24 18:48:39.64] local.info: [1.189ms] [rows:1] SELECT * FROM "users" WHERE "users"."id" = '1'
```
Register Dan again, log him in and delete him. His token is now refused on the task list too:
```bash
$ curl http://localhost:3000/api/v1/tasks -H "Authorization: Bearer $TOKEN"
{"error":"The user no longer exists."}
```
A query on every request:
A JWT can be checked without the database, and that is much of its appeal. TaskFlow already asks Redis on each request whether the token was logged out, and now it reads the user from Postgres too, even for `/tasks`, which doesn't use it. That is one lookup by primary key, and a deleted user is shut out at once.
## Limiting login attempts
Nothing stops a script from trying passwords at login all day. A [rate limiter](https://www.goravel.dev/the-basics/routing#rate-limiting) gives a rule a name, and the built-in `Throttle` middleware applies it to a route. Register it in the `WithCallback` from [Models & Migrations](https://devchalk.com/goravel/04-models-migrations#the-migration):
```diff lang="go" title="bootstrap/app.go" collapse={10-26} t1:key=/By[(](email)[)]/
package bootstrap
import (
"github.com/goravel/framework/contracts/database/schema"
contractsfoundation "github.com/goravel/framework/contracts/foundation"
+ "github.com/goravel/framework/contracts/http"
"github.com/goravel/framework/foundation"
+ "github.com/goravel/framework/http/limit"
"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{}},
})
+
+ facades.RateLimiter().For("login", func(ctx http.Context) http.Limit {
+ email := ctx.Request().Input("email")
+
+ return limit.PerMinute(5).By(email).Response(func(ctx http.Context) {
+ ctx.Response().Json(429, map[string]any{
+ "error": "Too many login attempts. Try again in a minute.",
+ }).Abort()
+ })
+ })
}).
Create()
}
```
- `PerMinute(5)` allows five tries a minute.
- `By` sets what is counted, here the email. A script guessing one account gets five tries a minute. In return, anyone who knows an email can keep its owner locked out by sending five requests a minute.
- `Response` sets the answer once the limit is hit. Without it, the client gets a `429` with an empty body.
Put `Throttle("login")` on the login route. Goravel's middleware package has the same name as TaskFlow's, so it is imported as `httpmiddleware`:
```diff lang="go" title="routes/api.go" collapse={11-16} collapse={22-31}
package routes
import (
"taskflow/app/facades"
"taskflow/app/http/controllers"
"taskflow/app/http/middleware"
"github.com/goravel/framework/contracts/route"
+ httpmiddleware "github.com/goravel/framework/http/middleware"
)
func Api() {
authController := controllers.NewAuthController()
taskController := controllers.NewTaskController()
userController := controllers.NewUserController()
facades.Route().Prefix("api/v1").Group(func(router route.Router) {
router.Post("register", authController.Register)
- router.Post("login", authController.Login)
+ router.Middleware(httpmiddleware.Throttle("login")).Post("login", authController.Login)
router.Post("refresh", authController.Refresh)
router.Middleware(&middleware.Auth{}).Group(func(router route.Router) {
router.Get("me", authController.Me)
router.Post("logout", authController.Logout)
router.Post("tasks", taskController.Store).Name("tasks.create")
router.Get("tasks/{id}", taskController.Show)
router.Get("tasks", taskController.Index)
router.Get("users", userController.Index)
router.Get("users/{id}", userController.Show)
})
})
}
```
Try a wrong password six times:
```bash
$ for i in 1 2 3 4 5 6; do curl -s -o /dev/null -w "%{http_code} " -X POST http://localhost:3000/api/v1/login -H "Content-Type: application/json" -d '{"email":"ana@example.com","password":"wrong"}'; done; echo
401 401 401 401 401 429
```
The sixth gets `429`. Logins that work count too, because `Throttle` counts every request. Counting only failures would mean moving the limit into the `Login` handler.
### The email as it was typed
The limiter counts the email exactly as it was sent, so check whether another spelling gets past it. Within the minute, log in as Ana with the right password, then with capitals, then with a space in front:
```bash
$ curl -X POST http://localhost:3000/api/v1/login \
-H "Content-Type: application/json" \
-d '{"email":"ana@example.com","password":"password"}'
{"error":"Too many login attempts. Try again in a minute."}
$ curl -X POST http://localhost:3000/api/v1/login \
-H "Content-Type: application/json" \
-d '{"email":"ANA@EXAMPLE.COM","password":"password"}'
{"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9…"}
$ curl -X POST http://localhost:3000/api/v1/login \
-H "Content-Type: application/json" \
-d '{"email":" ana@example.com","password":"password"}'
{"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9…"}
```
Ana's email is blocked, but both other spellings work. The limiter reads the email before the form request trims and lowercases it, so each spelling gets a counter of its own. Redis shows three:
```bash t1:key=/login:0:(.+)$/
$ docker compose exec redis redis-cli --scan --pattern '*throttle*'
Goravel_cache:throttle:login:0:ANA@EXAMPLE.COM
Goravel_cache:throttle:login:0:ana@example.com
Goravel_cache:throttle:login:0: ana@example.com
```
Before passing the email to `By`, trim it and lowercase it, as the form request's [filters](https://devchalk.com/goravel/09-register-login-jwt#registering) do:
```diff lang="go" title="bootstrap/app.go" collapse={6-33}
package bootstrap
import (
+ "strings"
+
"github.com/goravel/framework/contracts/database/schema"
contractsfoundation "github.com/goravel/framework/contracts/foundation"
"github.com/goravel/framework/contracts/http"
"github.com/goravel/framework/foundation"
"github.com/goravel/framework/http/limit"
"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{}},
})
facades.RateLimiter().For("login", func(ctx http.Context) http.Limit {
- email := ctx.Request().Input("email")
+ email := strings.ToLower(strings.TrimSpace(ctx.Request().Input("email")))
return limit.PerMinute(5).By(email).Response(func(ctx http.Context) {
ctx.Response().Json(429, map[string]any{
"error": "Too many login attempts. Try again in a minute.",
}).Abort()
})
})
}).
Create()
}
```
Wait a minute for the old counters to run out. Now five wrong tries in different spellings share one counter, and the right password is refused too until the minute is up:
```bash
$ for email in "ana@example.com" "Ana@Example.com" " ana@example.com" "ANA@EXAMPLE.COM" "ana@example.com "; do curl -s -o /dev/null -w "%{http_code} " -X POST http://localhost:3000/api/v1/login -H "Content-Type: application/json" -d "{\"email\":\"$email\",\"password\":\"wrong\"}"; done; echo
401 401 401 401 401
$ curl -i -X POST http://localhost:3000/api/v1/login \
-H "Content-Type: application/json" \
-d '{"email":"ana@example.com","password":"password"}'
HTTP/1.1 429 Too Many Requests
Content-Type: application/json; charset=utf-8
Retry-After: 60
X-Ratelimit-Limit: 5
X-Ratelimit-Remaining: 0
X-Ratelimit-Reset: 1792867791
Content-Length: 59
{"error":"Too many login attempts. Try again in a minute."}
```
- `X-Ratelimit-Limit` and `X-Ratelimit-Remaining` say how many tries the minute allows and how many are left.
- `Retry-After` is the number of seconds until the next try.
- `X-Ratelimit-Reset` is when the counter starts again, in Unix time.
There is one key in Redis now:
```bash t1:key=/login:0:(.+)$/
$ docker compose exec redis redis-cli --scan --pattern '*throttle*'
Goravel_cache:throttle:login:0:ana@example.com
```
Register Eve and log her in. Her email has its own counter, so she gets through, with four tries left:
```bash
$ curl -X POST http://localhost:3000/api/v1/register \
-H "Content-Type: application/json" \
-d '{"name":"Eve","email":"eve@example.com","password":"secret123"}'
{"created_at":"2026-10-24 18:48:52","updated_at":"2026-10-24 18:48:52","id":7,"name":"Eve","email":"eve@example.com","role":"member","created_tasks":null,"assigned_tasks":null}
$ curl -i -X POST http://localhost:3000/api/v1/login \
-H "Content-Type: application/json" \
-d '{"email":"eve@example.com","password":"secret123"}'
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
X-Ratelimit-Limit: 5
X-Ratelimit-Remaining: 4
Content-Length: 171
{"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9…"}
```
What the email limit doesn't stop:
It doesn't stop one client from trying a common password against many emails, and register still tells anyone whether an email has an account. Both need a limit per client, and Goravel counts a client by IP. `Throttle` does that when a limiter has no key, counting by `ctx.Request().Ip()` and the path.
Counting the email and the IP together, as Laravel's starter kits do, is weaker than the email alone. With the Gin driver, `Ip()` trusts the `X-Forwarded-For` header from any client, so a client can send a new IP on every request, and one account gets unlimited tries. Change the key to the email and the IP:
```diff lang="go" title="bootstrap/app.go" collapse={1-33}
package bootstrap
import (
"strings"
"github.com/goravel/framework/contracts/database/schema"
contractsfoundation "github.com/goravel/framework/contracts/foundation"
"github.com/goravel/framework/contracts/http"
"github.com/goravel/framework/foundation"
"github.com/goravel/framework/http/limit"
"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{}},
})
facades.RateLimiter().For("login", func(ctx http.Context) http.Limit {
email := strings.ToLower(strings.TrimSpace(ctx.Request().Input("email")))
- return limit.PerMinute(5).By(email).Response(func(ctx http.Context) {
+ return limit.PerMinute(5).By(email + "|" + ctx.Request().Ip()).Response(func(ctx http.Context) {
ctx.Response().Json(429, map[string]any{
"error": "Too many login attempts. Try again in a minute.",
}).Abort()
})
})
}).
Create()
}
```
Lock Ana's email, then send a new header on each try:
```bash
$ for i in 1 2 3 4 5 6; do curl -s -o /dev/null -w "%{http_code} " -X POST http://localhost:3000/api/v1/login -H "Content-Type: application/json" -d '{"email":"ana@example.com","password":"wrong"}'; done; echo
401 401 401 401 401 429
$ for i in 1 2 3 4 5 6; do curl -s -o /dev/null -w "%{http_code} " -X POST http://localhost:3000/api/v1/login -H "Content-Type: application/json" -H "X-Forwarded-For: 10.0.0.$i" -d '{"email":"ana@example.com","password":"wrong"}'; done; echo
401 401 401 401 401 401
```
Every try gets a counter of its own, so the email never locks. Change the key back to `By(email)`. TaskFlow counts by email only. A per-IP limit falls to the same header. A limit per client needs an IP the client can't fake, which means telling the server which proxies to trust.
The task and user routes now turn away anyone without a valid token, handlers get the signed-in user from the middleware, and login allows five tries a minute per email. A member can still do anything a manager can.
---
Previous: [09. Register & Login with JWT](https://devchalk.com/goravel/09-register-login-jwt.md)
Next: [11. Task CRUD](https://devchalk.com/goravel/11-task-crud.md)
Series: [Goravel](https://devchalk.com/goravel.md)
# Task CRUD
> The task endpoints, with a status filter and pages.
Goravel series, part 11 of 20. By Krishan Kumar. Published 2026-10-25.
Source: https://devchalk.com/goravel/11-task-crud
Code: https://github.com/krishankumar01/goravel-taskflow/tree/part-11
In [Auth Middleware & Rate Limits](https://devchalk.com/goravel/10-auth-middleware), the task routes started asking for a login, but they still only echo what they get:
- `Store` saves nothing.
- The list sends its own query string back.
- There is no way to change or delete a task.
In this part the task routes read and write Postgres. The list gets a status filter and pages, and create, show, update and delete all work. First, the roles and statuses get their own package, and errors stop reaching the client.
Start from the Auth Middleware & Rate Limits code:
```bash
git clone https://github.com/krishankumar01/goravel-taskflow
cd goravel-taskflow
git checkout part-10
cp .env.example .env
go run . artisan key:generate
go run . artisan jwt:secret
docker compose up -d
go run . artisan migrate:fresh --seed
go install github.com/air-verse/air@latest
air
```
## Roles and statuses in their own package
[Factories & Seeding](https://devchalk.com/goravel/07-factories-seeding#the-user-factory) wrote `"member"` and `"pending"` as plain strings. The factories can't import the models, because the models already import the factories, and Go doesn't allow two packages to import each other. So `Role` and `Status` move to `app/enums`, which imports neither:
```go title="app/enums/role.go"
package enums
type Role string
const (
RoleManager Role = "manager"
RoleMember Role = "member"
)
```
```go title="app/enums/status.go" t1:pending=/(StatusPending)/
package enums
type Status string
const (
StatusPending Status = "pending"
StatusInProgress Status = "in-progress"
StatusDone Status = "done"
)
```
The models use the types from there:
```diff lang="go" title="app/models/task.go" collapse={1-5} collapse={20-25}
package models
import (
"github.com/goravel/framework/contracts/database/factory"
"github.com/goravel/framework/database/orm"
"github.com/goravel/framework/support/carbon"
+ "taskflow/app/enums"
"taskflow/database/factories"
-)
-
-type Status string
-
-const (
- StatusPending Status = "pending"
- StatusInProgress Status = "in-progress"
- StatusDone Status = "done"
)
type Task struct {
orm.Model
CreatedBy uint `gorm:"not null" json:"created_by"`
Creator *User `gorm:"foreignKey:CreatedBy" json:"creator"`
AssignedTo *uint `json:"assigned_to"`
Assignee *User `gorm:"foreignKey:AssignedTo" json:"assignee"`
Title string `gorm:"size:255;not null" json:"title"`
Description string `gorm:"type:text;not null" json:"description"`
- Status Status `gorm:"type:enum('pending','in-progress','done');default:'pending';not null" json:"status"`
+ Status enums.Status `gorm:"type:enum('pending','in-progress','done');default:'pending';not null" json:"status"`
DueAt *carbon.DateTime `json:"due_at"`
}
func (t *Task) Factory() factory.Factory {
return &factories.TaskFactory{}
}
```
In the task factory, the plain strings become typed values, like `StatusPending`:
```diff lang="go" title="database/factories/task_factory.go" collapse={10-22} collapse={30-36} t1:pending=/("pending"|StatusPending)/
package factories
import (
"github.com/brianvoe/gofakeit/v7"
"github.com/goravel/framework/support/carbon"
+
+ "taskflow/app/enums"
)
type TaskFactory struct {
}
// Definition Define the model's default state.
func (f *TaskFactory) Definition() map[string]any {
title := gofakeit.RandomString([]string{
"Fix the login bug",
"Write the API docs",
"Review the pull request",
"Plan the next release",
"Update the dependencies",
"Add tests for tasks",
"Clean up old logs",
"Reply to support emails",
})
- status := gofakeit.RandomString([]string{"pending", "in-progress", "done"})
+ statuses := []enums.Status{enums.StatusPending, enums.StatusInProgress, enums.StatusDone}
+ status := statuses[gofakeit.Number(0, len(statuses)-1)]
due := carbon.Now().AddDays(gofakeit.Number(1, 30)).StartOfDay().SetHour(9)
return map[string]any{
"Title": title,
"Description": gofakeit.HackerPhrase(),
"Status": status,
"DueAt": carbon.NewDateTime(due),
}
}
```
The user model, the user factory, both seeders and `UserController` change the same way:
The same change in five more files:
```diff lang="go" title="app/models/user.go" collapse={30-35}
package models
import (
"github.com/goravel/framework/contracts/database/factory"
"github.com/goravel/framework/database/orm"
+ "taskflow/app/enums"
"taskflow/database/factories"
-)
-
-type Role string
-
-const (
- RoleManager Role = "manager"
- RoleMember Role = "member"
)
type User struct {
orm.Model
- Name string `gorm:"size:255;not null" json:"name"`
- Email string `gorm:"size:255;unique;not null" json:"email"`
- Password string `gorm:"size:255;not null" json:"-"`
- Role Role `gorm:"type:enum('manager','member');default:'member';not null" json:"role"`
+ Name string `gorm:"size:255;not null" json:"name"`
+ Email string `gorm:"size:255;unique;not null" json:"email"`
+ Password string `gorm:"size:255;not null" json:"-"`
+ Role enums.Role `gorm:"type:enum('manager','member');default:'member';not null" json:"role"`
CreatedTasks []Task `gorm:"foreignKey:CreatedBy" json:"created_tasks"`
AssignedTasks []Task `gorm:"foreignKey:AssignedTo" json:"assigned_tasks"`
}
func (u *User) Factory() factory.Factory {
return &factories.UserFactory{}
}
```
```diff lang="go" title="database/factories/user_factory.go" collapse={9-18}
package factories
import (
"github.com/brianvoe/gofakeit/v7"
+ "taskflow/app/enums"
"taskflow/app/facades"
)
type UserFactory struct {
}
// Definition Define the model's default state.
func (f *UserFactory) Definition() map[string]any {
password, _ := facades.Hash().Make("password")
return map[string]any{
"Name": gofakeit.Name(),
"Email": gofakeit.Email(),
"Password": password,
- "Role": "member",
+ "Role": enums.RoleMember,
}
}
```
```diff lang="go" title="database/seeders/user_seeder.go" collapse={7-20} collapse={28-32}
package seeders
import (
+ "taskflow/app/enums"
"taskflow/app/facades"
"taskflow/app/models"
)
type UserSeeder struct {
}
// Signature The name and signature of the seeder.
func (s *UserSeeder) Signature() string {
return "UserSeeder"
}
// Run executes the seeder logic.
func (s *UserSeeder) Run() error {
var manager models.User
err := facades.Orm().Factory().Create(&manager, map[string]any{
"Name": "Ana",
"Email": "ana@example.com",
- "Role": models.RoleManager,
+ "Role": enums.RoleManager,
})
if err != nil {
return err
}
var members []models.User
return facades.Orm().Factory().Count(3).Create(&members)
}
```
```diff lang="go" title="database/seeders/task_seeder.go" collapse={7-17} collapse={31-48}
package seeders
import (
+ "taskflow/app/enums"
"taskflow/app/facades"
"taskflow/app/models"
)
type TaskSeeder struct {
}
// Signature The name and signature of the seeder.
func (s *TaskSeeder) Signature() string {
return "TaskSeeder"
}
// Run executes the seeder logic.
func (s *TaskSeeder) Run() error {
var manager models.User
- err := facades.Orm().Query().Where("role", models.RoleManager).FirstOrFail(&manager)
+ err := facades.Orm().Query().Where("role", enums.RoleManager).FirstOrFail(&manager)
if err != nil {
return err
}
var members []models.User
- err = facades.Orm().Query().Where("role", models.RoleMember).Find(&members)
+ err = facades.Orm().Query().Where("role", enums.RoleMember).Find(&members)
if err != nil {
return err
}
for _, member := range members {
var tasks []models.Task
err = facades.Orm().Factory().Count(2).Create(&tasks, map[string]any{
"CreatedBy": manager.ID,
"AssignedTo": &member.ID,
})
if err != nil {
return err
}
}
var unassigned models.Task
return facades.Orm().Factory().Create(&unassigned, map[string]any{
"CreatedBy": manager.ID,
})
}
```
```diff lang="go" title="app/http/controllers/user_controller.go" collapse={7-19} collapse={26-49}
package controllers
import (
+ "taskflow/app/enums"
"taskflow/app/facades"
"taskflow/app/models"
"github.com/goravel/framework/contracts/database/orm"
"github.com/goravel/framework/contracts/http"
)
type UserController struct{}
func NewUserController() *UserController {
return &UserController{}
}
func (r *UserController) Index(ctx http.Context) http.Response {
var users []models.User
err := facades.Orm().Query().
With("AssignedTasks", func(query orm.Query) orm.Query {
- return query.Where("status <> ?", models.StatusDone)
+ return query.Where("status <> ?", enums.StatusDone)
}).
With("AssignedTasks.Creator").
Find(&users)
if err != nil {
return ctx.Response().Json(500, map[string]any{
"error": err.Error(),
})
}
return ctx.Response().Json(200, users)
}
func (r *UserController) Show(ctx http.Context) http.Response {
var user models.User
err := facades.Orm().Query().
With("CreatedTasks").
With("AssignedTasks").
Find(&user, ctx.Request().RouteInt("id"))
if err != nil {
return ctx.Response().Json(500, map[string]any{
"error": err.Error(),
})
}
return ctx.Response().Json(200, user)
}
```
The tables don't change. Seed again to check the factories:
```bash
$ go run . artisan migrate:fresh --seed
SUCCESS Dropped all tables successfully
INFO Running migration
INFO Running: 20210101000002_create_jobs_table
INFO Running: 20261022221906_create_users_table
INFO Running: 20261023063944_create_tasks_table
SUCCESS Migration success
SUCCESS Database seeding completed successfully.
SUCCESS Migration fresh success
```
The seed replaced the users, so an old token may belong to a user who no longer exists. Log in as Ana again:
```bash
$ curl -X POST http://localhost:3000/api/v1/login \
-H "Content-Type: application/json" \
-d '{"email":"ana@example.com","password":"password"}'
{"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9…"}
$ TOKEN=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9…
```
## Errors stay in the log
[Relationships: Belongs To](https://devchalk.com/goravel/05-relationships-belongs-to#finding-a-row-with-find) sent `err.Error()` to the client when a query failed, and said it would be replaced. A database error can name the database user, the database, the host and the port. The client only needs a plain message, and the real error goes to the [log](https://www.goravel.dev/the-basics/logging#write-log-messages):
```diff lang="go" title="app/http/controllers/user_controller.go" collapse={1-24} collapse={33-41} t3:logged=/facades[.]Log[(][)][.](Error)/
package controllers
import (
"taskflow/app/enums"
"taskflow/app/facades"
"taskflow/app/models"
"github.com/goravel/framework/contracts/database/orm"
"github.com/goravel/framework/contracts/http"
)
type UserController struct{}
func NewUserController() *UserController {
return &UserController{}
}
func (r *UserController) Index(ctx http.Context) http.Response {
var users []models.User
err := facades.Orm().Query().
With("AssignedTasks", func(query orm.Query) orm.Query {
return query.Where("status <> ?", enums.StatusDone)
}).
With("AssignedTasks.Creator").
Find(&users)
if err != nil {
+ facades.Log().Error(err)
return ctx.Response().Json(500, map[string]any{
- "error": err.Error(),
+ "error": "Could not list the users.",
})
}
return ctx.Response().Json(200, users)
}
func (r *UserController) Show(ctx http.Context) http.Response {
var user models.User
err := facades.Orm().Query().
With("CreatedTasks").
With("AssignedTasks").
Find(&user, ctx.Request().RouteInt("id"))
if err != nil {
+ facades.Log().Error(err)
return ctx.Response().Json(500, map[string]any{
- "error": err.Error(),
+ "error": "Could not load the user.",
})
}
return ctx.Response().Json(200, user)
}
```
`facades.Log().Error(err)` writes the error to the day's log file, and the client gets "Could not list the users."
Every other `500` does the same. The ones in `AuthController` and the auth middleware already sent a plain message, but kept no record of the error:
The same line in front of every other 500:
```diff lang="go" title="app/http/controllers/task_controller.go" collapse={1-22} collapse={31-43} collapse={53-62} collapse={71-77} collapse={87-102}
package controllers
import (
"taskflow/app/facades"
"taskflow/app/http/requests"
"taskflow/app/models"
"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 {
var request requests.StoreTaskRequest
errors, err := ctx.Request().ValidateRequest(&request)
if err != nil {
+ facades.Log().Error(err)
return ctx.Response().Json(500, map[string]any{
- "error": err.Error(),
+ "error": "Could not save the task.",
})
}
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 {
+ facades.Log().Error(err)
return ctx.Response().Json(500, map[string]any{
- "error": err.Error(),
+ "error": "Could not load the task.",
})
}
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 {
+ facades.Log().Error(err)
return ctx.Response().Json(500, map[string]any{
- "error": err.Error(),
+ "error": "Could not load the task.",
})
}
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 {
+ facades.Log().Error(err)
return ctx.Response().Json(500, map[string]any{
- "error": err.Error(),
+ "error": "Could not list the tasks.",
})
}
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,
})
}
```
```diff lang="go" title="app/http/controllers/auth_controller.go" collapse={1-39} collapse={45-52} collapse={58-63} collapse={70-77} collapse={83-90} collapse={96-108} collapse={114-136} collapse={144-151} collapse={157-161}
package controllers
import (
"errors"
"sync"
"taskflow/app/facades"
"taskflow/app/http/authuser"
"taskflow/app/http/requests"
"taskflow/app/models"
"github.com/goravel/framework/auth"
"github.com/goravel/framework/contracts/http"
)
// noUserHash is a bcrypt hash of a password nobody has, made with the same cost as the
// real ones. An unknown email is checked against it, so it takes as long to refuse as a
// wrong password.
var noUserHash = sync.OnceValue(func() string {
hash, err := facades.Hash().Make("no user has this password")
if err != nil {
panic(err)
}
return hash
})
type AuthController struct {
// Dependent services
}
func NewAuthController() *AuthController {
return &AuthController{
// Inject services
}
}
func (r *AuthController) Register(ctx http.Context) http.Response {
var request requests.RegisterRequest
validationErrors, err := ctx.Request().ValidateRequest(&request)
if err != nil {
+ facades.Log().Error(err)
return ctx.Response().Json(500, map[string]any{
"error": "Could not create the account.",
})
}
if validationErrors != nil {
return ctx.Response().Json(422, map[string]any{
"errors": validationErrors.All(),
})
}
password, err := facades.Hash().Make(request.Password)
if err != nil {
+ facades.Log().Error(err)
return ctx.Response().Json(500, map[string]any{
"error": "Could not create the account.",
})
}
user := models.User{
Name: request.Name,
Email: request.Email,
Password: password,
}
if err := facades.Orm().WithContext(ctx).Query().Create(&user); err != nil {
+ facades.Log().Error(err)
return ctx.Response().Json(500, map[string]any{
"error": "Could not create the account.",
})
}
return ctx.Response().Json(201, user)
}
func (r *AuthController) Login(ctx http.Context) http.Response {
var request requests.LoginRequest
validationErrors, err := ctx.Request().ValidateRequest(&request)
if err != nil {
+ facades.Log().Error(err)
return ctx.Response().Json(500, map[string]any{
"error": "Could not log in.",
})
}
if validationErrors != nil {
return ctx.Response().Json(422, map[string]any{
"errors": validationErrors.All(),
})
}
var user models.User
if err := facades.Orm().WithContext(ctx).Query().Where("email", request.Email).First(&user); err != nil {
+ facades.Log().Error(err)
return ctx.Response().Json(500, map[string]any{
"error": "Could not log in.",
})
}
hash := user.Password
if user.ID == 0 {
hash = noUserHash()
}
if !facades.Hash().Check(request.Password, hash) || user.ID == 0 {
return ctx.Response().Json(401, map[string]any{
"error": "The email or password is wrong.",
})
}
token, err := facades.Auth(ctx).Login(&user)
if err != nil {
+ facades.Log().Error(err)
return ctx.Response().Json(500, map[string]any{
"error": "Could not log in.",
})
}
return ctx.Response().Json(200, map[string]any{
"token": token,
})
}
func (r *AuthController) Me(ctx http.Context) http.Response {
return ctx.Response().Json(200, authuser.From(ctx))
}
func (r *AuthController) Refresh(ctx http.Context) http.Response {
_, err := facades.Auth(ctx).Parse(ctx.Request().Header("Authorization"))
if err != nil && !errors.Is(err, auth.ErrorTokenExpired) {
return ctx.Response().Json(401, map[string]any{
"error": err.Error(),
})
}
token, err := facades.Auth(ctx).Refresh()
if errors.Is(err, auth.ErrorRefreshTimeExceeded) {
return ctx.Response().Json(401, map[string]any{
"error": err.Error(),
})
}
if err != nil {
+ facades.Log().Error(err)
return ctx.Response().Json(500, map[string]any{
"error": "Could not refresh the token.",
})
}
return ctx.Response().Json(200, map[string]any{
"token": token,
})
}
func (r *AuthController) Logout(ctx http.Context) http.Response {
if err := facades.Auth(ctx).Logout(); err != nil {
+ facades.Log().Error(err)
return ctx.Response().Json(500, map[string]any{
"error": "Could not log out.",
})
}
return ctx.Response().NoContent()
}
```
```diff lang="go" title="app/http/middleware/auth.go" collapse={1-33} collapse={40-46}
package middleware
import (
"errors"
"taskflow/app/facades"
"taskflow/app/http/authuser"
"taskflow/app/models"
"github.com/goravel/framework/contracts/http"
frameworkerrors "github.com/goravel/framework/errors"
)
type Auth struct{}
func (m *Auth) Signature() string {
return "Auth"
}
func (m *Auth) Handle(ctx http.Context) {
if _, err := facades.Auth(ctx).Parse(ctx.Request().Header("Authorization")); err != nil {
ctx.Response().Json(401, map[string]any{
"error": err.Error(),
}).Abort()
return
}
var user models.User
if err := facades.Auth(ctx).User(&user); err != nil {
if errors.Is(err, frameworkerrors.OrmRecordNotFound) {
ctx.Response().Json(401, map[string]any{
"error": "The user no longer exists.",
}).Abort()
return
}
+ facades.Log().Error(err)
ctx.Response().Json(500, map[string]any{
"error": "Could not load the user.",
}).Abort()
return
}
authuser.Set(ctx, &user)
ctx.Request().Next()
}
```
Try it with Postgres down:
1. Stop Postgres.
2. Stop `air` with Ctrl+C and start it again, so the app has no open connection.
3. Ask for a user.
The auth middleware fails first, because it loads the signed-in user before any handler runs. Its answer is "Could not load the user."
```bash t2:msg=/"(Could not load the user[.])"/
$ docker compose stop postgres
$ curl http://localhost:3000/api/v1/users/2 -H "Authorization: Bearer $TOKEN"
{"error":"Could not load the user."}
$ docker compose start postgres
```
The log has the reason:
```text wrap t3:logged=/local[.](error)/
[2026-10-24 20:51:40.517] local.error: failed to connect to `user=goravel database=taskflow`: 127.0.0.1:5433 (127.0.0.1): dial error: dial tcp 127.0.0.1:5433: connect: connection refused
```
## Listing tasks
The list from [Validation with Form Requests](https://devchalk.com/goravel/08-validation-form-requests#validating-the-query-string) checks `status` and `page`, then sends them back. Now it reads one page of tasks at a time, newest first, with [`Paginate`](https://www.goravel.dev/orm/getting-started#paginate):
```diff lang="go" title="app/http/controllers/task_controller.go" collapse={1-71} collapse={82-87} t1:perpage=/Paginate[(]page, (perPage)/ t2:total=/&(total)[)]/
package controllers
import (
"taskflow/app/facades"
"taskflow/app/http/requests"
"taskflow/app/models"
"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 {
var request requests.StoreTaskRequest
errors, err := ctx.Request().ValidateRequest(&request)
if err != nil {
facades.Log().Error(err)
return ctx.Response().Json(500, map[string]any{
"error": "Could not save the task.",
})
}
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 {
facades.Log().Error(err)
return ctx.Response().Json(500, map[string]any{
"error": "Could not load the task.",
})
}
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 {
facades.Log().Error(err)
return ctx.Response().Json(500, map[string]any{
"error": "Could not load the task.",
})
}
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",
+ "status": "in:pending,in-progress,done",
+ "page": "bail|filled|integer|min:1",
+ "per_page": "bail|filled|integer|min:1|max:100",
})
if err != nil {
facades.Log().Error(err)
return ctx.Response().Json(500, map[string]any{
"error": "Could not list the tasks.",
})
}
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")
+ perPage := ctx.Request().QueryInt("per_page", 15)
+
+ query := facades.Orm().WithContext(ctx).Query()
+ if status := ctx.Request().Query("status"); status != "" {
+ query = query.Where("status", status)
+ }
+
+ var tasks []models.Task
+ var total int64
+ err = query.With("Creator").With("Assignee").OrderByDesc("id").Paginate(page, perPage, &tasks, &total)
+ if err != nil {
+ facades.Log().Error(err)
+ return ctx.Response().Json(500, map[string]any{
+ "error": "Could not list the tasks.",
+ })
+ }
return ctx.Response().Json(200, map[string]any{
- "status": status,
- "page": page,
- "filters": filters,
+ "data": tasks,
+ "meta": map[string]any{
+ "page": page,
+ "per_page": perPage,
+ "total": total,
+ },
})
}
```
- `per_page` is the page size, from 1 to 100, and 15 when it is missing. `max:100` compares numbers, because the field also has `integer`.
- The status filter is added only when `status` is sent.
- `Paginate` counts the matching tasks into `total`, then reads one page of them.
- The `filters` map from [Reading Request Data](https://devchalk.com/goravel/03-reading-request-data#query-strings) goes. It was only there to show `QueryMap`.
In `requests.http`, the list asks for pending tasks, five at a time:
```diff lang="http" title="requests.http" collapse={1-60} collapse={70-79}
### Register
POST http://localhost:3000/api/v1/register
Content-Type: application/json
{
"name": "Ben",
"email": "ben@example.com",
"password": "secret123"
}
### Log in
POST http://localhost:3000/api/v1/login
Content-Type: application/json
{
"email": "ben@example.com",
"password": "secret123"
}
> {%
client.global.set("token", response.body.token);
%}
### Me
GET http://localhost:3000/api/v1/me
Authorization: Bearer {{token}}
### Refresh
POST http://localhost:3000/api/v1/refresh
Authorization: Bearer {{token}}
> {%
client.global.set("token", response.body.token);
%}
### Log out
POST http://localhost:3000/api/v1/logout
Authorization: Bearer {{token}}
### Create task
POST http://localhost:3000/api/v1/tasks
Authorization: Bearer {{token}}
Content-Type: application/json
{
"title": "Ship v1",
"description": "The first release"
}
### Create task with bad data
POST http://localhost:3000/api/v1/tasks
Authorization: Bearer {{token}}
Content-Type: application/json
{
"title": " ",
"assigned_to": 99,
"due_at": "2020-01-01"
}
### Show task
GET http://localhost:3000/api/v1/tasks/1
Authorization: Bearer {{token}}
### List tasks
-GET http://localhost:3000/api/v1/tasks?status=done&page=2&filters[priority]=high
+GET http://localhost:3000/api/v1/tasks?status=pending&per_page=5
Authorization: Bearer {{token}}
### List users
GET http://localhost:3000/api/v1/users
Authorization: Bearer {{token}}
### Show user
GET http://localhost:3000/api/v1/users/2
Authorization: Bearer {{token}}
### Hello
GET http://localhost:3000/hello
```
Ask for the done tasks, one per page:
```bash
$ curl "http://localhost:3000/api/v1/tasks?status=done&per_page=1" -H "Authorization: Bearer $TOKEN"
```
```json title="GET /api/v1/tasks?status=done&per_page=1" t1:perpage=/"per_page": (1)/ t2:total=/"total": ([0-9]+)/
{
"data": [
{
"created_at": "2026-10-24 20:51:32",
"updated_at": "2026-10-24 20:51:32",
"id": 6,
"created_by": 1,
"creator": {
"created_at": "2026-10-24 20:51:31",
"updated_at": "2026-10-24 20:51:31",
"id": 1,
"name": "Ana",
"email": "ana@example.com",
"role": "manager",
"created_tasks": null,
"assigned_tasks": null
},
"assigned_to": 4,
"assignee": {
"created_at": "2026-10-24 20:51:32",
"updated_at": "2026-10-24 20:51:32",
"id": 4,
"name": "Julio Reese",
"email": "raegangerlach@hudson.org",
"role": "member",
"created_tasks": null,
"assigned_tasks": null
},
"title": "Fix the login bug",
"description": "Backing up the bandwidth won't do anything, we need to back up the optical IB system!",
"status": "done",
"due_at": "2026-11-21 09:00:00"
}
],
"meta": {
"page": 1,
"per_page": 1,
"total": 3
}
}
```
`total` counts every done task, and the next pages hold the rest. The [query log](https://devchalk.com/goravel/05-relationships-belongs-to#the-query-log) has:
- the count, with the same filter;
- the page;
- the creator and the assignee of the task on that page.
```text wrap t1:perpage=/LIMIT (1)$/ t2:total=/SELECT (count[(][*][)])/
[2026-10-24 20:51:50.863] local.info: [3.681ms] [rows:1] SELECT count(*) FROM "tasks" WHERE "status" = 'done'
[2026-10-24 20:51:50.866] local.info: [0.728ms] [rows:1] SELECT * FROM "users" WHERE "users"."id" = 4
[2026-10-24 20:51:50.866] local.info: [0.653ms] [rows:1] SELECT * FROM "users" WHERE "users"."id" = 1
[2026-10-24 20:51:50.866] local.info: [3.030ms] [rows:1] SELECT * FROM "tasks" WHERE "status" = 'done' ORDER BY id DESC LIMIT 1
```
Log lines in this part leave out the `[Context]` and `[Trace]` lines Goravel writes under some of them, and from here on the middleware's query for the signed-in user.
A page size out of range, or a status that doesn't exist, gets a `422`:
```bash
$ curl "http://localhost:3000/api/v1/tasks?per_page=150&status=finished" \
-H "Authorization: Bearer $TOKEN"
{"errors":{"per_page":{"max":"The per page field must not be greater than 100."},"status":{"in":"The selected status is invalid."}}}
```
## Creating a task
`Store` checks the body with `StoreTaskRequest` and sends it back. Now it [creates](https://www.goravel.dev/orm/getting-started#create) the task, with the signed-in user as its creator:
```diff lang="go" title="app/http/controllers/task_controller.go" collapse={9-22} collapse={69-146} t2:createdby=/authuser[.]From[(]ctx[)][.](ID)/ t1:created=/Json[(](201)/
package controllers
import (
+ "taskflow/app/enums"
"taskflow/app/facades"
+ "taskflow/app/http/authuser"
"taskflow/app/http/requests"
"taskflow/app/models"
"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 {
var request requests.StoreTaskRequest
- errors, err := ctx.Request().ValidateRequest(&request)
+ validationErrors, err := ctx.Request().ValidateRequest(&request)
if err != nil {
facades.Log().Error(err)
return ctx.Response().Json(500, map[string]any{
"error": "Could not save the task.",
})
}
- if errors != nil {
+ if validationErrors != nil {
return ctx.Response().Json(422, map[string]any{
- "errors": errors.All(),
+ "errors": validationErrors.All(),
})
}
- return ctx.Response().Json(200, request)
+ task := models.Task{
+ CreatedBy: authuser.From(ctx).ID,
+ AssignedTo: request.AssignedTo,
+ Title: request.Title,
+ Description: request.Description,
+ Status: enums.StatusPending,
+ DueAt: request.DueAt,
+ }
+ if err := facades.Orm().WithContext(ctx).Query().Create(&task); err != nil {
+ facades.Log().Error(err)
+ return ctx.Response().Json(500, map[string]any{
+ "error": "Could not save the task.",
+ })
+ }
+
+ var saved models.Task
+ err = facades.Orm().WithContext(ctx).Query().With("Creator").With("Assignee").Find(&saved, task.ID)
+ if err != nil {
+ facades.Log().Error(err)
+ return ctx.Response().Json(500, map[string]any{
+ "error": "Could not load the task.",
+ })
+ }
+
+ return ctx.Response().Json(201, saved)
}
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 {
facades.Log().Error(err)
return ctx.Response().Json(500, map[string]any{
"error": "Could not load the task.",
})
}
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 {
facades.Log().Error(err)
return ctx.Response().Json(500, map[string]any{
"error": "Could not load the task.",
})
}
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",
"per_page": "bail|filled|integer|min:1|max:100",
})
if err != nil {
facades.Log().Error(err)
return ctx.Response().Json(500, map[string]any{
"error": "Could not list the tasks.",
})
}
if validator.Fails() {
return ctx.Response().Json(422, map[string]any{
"errors": validator.Errors().All(),
})
}
page := ctx.Request().QueryInt("page", 1)
perPage := ctx.Request().QueryInt("per_page", 15)
query := facades.Orm().WithContext(ctx).Query()
if status := ctx.Request().Query("status"); status != "" {
query = query.Where("status", status)
}
var tasks []models.Task
var total int64
err = query.With("Creator").With("Assignee").OrderByDesc("id").Paginate(page, perPage, &tasks, &total)
if err != nil {
facades.Log().Error(err)
return ctx.Response().Json(500, map[string]any{
"error": "Could not list the tasks.",
})
}
return ctx.Response().Json(200, map[string]any{
"data": tasks,
"meta": map[string]any{
"page": page,
"per_page": perPage,
"total": total,
},
})
}
```
- `created_by` is the `ID` of the user the middleware loaded, so a client can't choose who made a task.
- A new task starts as `enums.StatusPending`.
- After `Create`, the task is read again into `saved` with its creator and assignee, and the answer is `201 Created`.
- `errors` is renamed `validationErrors`, because the next section imports Go's `errors` package.
Create a task assigned to user 2:
```bash t1:created=/HTTP[/]1[.]1 (201) Created/
$ curl -i -X POST http://localhost:3000/api/v1/tasks \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"title":"Ship v2","description":"The second release","assigned_to":2}'
HTTP/1.1 201 Created
```
```json title="POST /api/v1/tasks" t2:createdby=/"created_by": (1)/
{
"created_at": "2026-10-24 20:51:57",
"updated_at": "2026-10-24 20:51:57",
"id": 8,
"created_by": 1,
"creator": {
"created_at": "2026-10-24 20:51:31",
"updated_at": "2026-10-24 20:51:31",
"id": 1,
"name": "Ana",
"email": "ana@example.com",
"role": "manager",
"created_tasks": null,
"assigned_tasks": null
},
"assigned_to": 2,
"assignee": {
"created_at": "2026-10-24 20:51:32",
"updated_at": "2026-10-24 20:51:32",
"id": 2,
"name": "Garrett Cook",
"email": "giles.wagner@hawkins.com",
"role": "member",
"created_tasks": null,
"assigned_tasks": null
},
"title": "Ship v2",
"description": "The second release",
"status": "pending",
"due_at": null
}
```
The log has the `exists` check for user 2, the insert with Ana's id, and the task read back with its two users:
```text wrap t2:createdby=/VALUES [(]'[^']+','[^']+',(1),/
[2026-10-24 20:51:57.074] local.info: [0.366ms] [rows:1] SELECT 1 FROM "users" WHERE "id" = 2 LIMIT 1
[2026-10-24 20:51:57.076] local.info: [1.592ms] [rows:1] INSERT INTO "tasks" ("created_at","updated_at","created_by","assigned_to","title","description","status","due_at") VALUES ('2026-10-24 20:51:57.074','2026-10-24 20:51:57.074',1,2,'Ship v2','The second release','pending',NULL) RETURNING "id"
[2026-10-24 20:51:57.077] local.info: [0.704ms] [rows:1] SELECT * FROM "users" WHERE "users"."id" = 2
[2026-10-24 20:51:57.078] local.info: [0.596ms] [rows:1] SELECT * FROM "users" WHERE "users"."id" = 1
[2026-10-24 20:51:57.078] local.info: [2.227ms] [rows:1] SELECT * FROM "tasks" WHERE "tasks"."id" = 8
```
## A task that does not exist
`Show` answers `200` with an empty task for an id that has no row, as [Relationships: Belongs To](https://devchalk.com/goravel/05-relationships-belongs-to#finding-a-row-with-find) showed. [`FindOrFail`](https://www.goravel.dev/orm/getting-started#not-found-error) returns an error instead, and `Show` turns it into a `404`:
```diff lang="go" title="app/http/controllers/task_controller.go" collapse={16-66} collapse={101-153} t1:missing=/"(The task does not exist[.])"/
package controllers
import (
+ "errors"
+
"taskflow/app/enums"
"taskflow/app/facades"
"taskflow/app/http/authuser"
"taskflow/app/http/requests"
"taskflow/app/models"
"github.com/goravel/framework/contracts/http"
+ frameworkerrors "github.com/goravel/framework/errors"
)
type TaskController struct {
// Dependent services
}
func NewTaskController() *TaskController {
return &TaskController{
// Inject services
}
}
func (r *TaskController) Store(ctx http.Context) http.Response {
var request requests.StoreTaskRequest
validationErrors, err := ctx.Request().ValidateRequest(&request)
if err != nil {
facades.Log().Error(err)
return ctx.Response().Json(500, map[string]any{
"error": "Could not save the task.",
})
}
if validationErrors != nil {
return ctx.Response().Json(422, map[string]any{
"errors": validationErrors.All(),
})
}
task := models.Task{
CreatedBy: authuser.From(ctx).ID,
AssignedTo: request.AssignedTo,
Title: request.Title,
Description: request.Description,
Status: enums.StatusPending,
DueAt: request.DueAt,
}
if err := facades.Orm().WithContext(ctx).Query().Create(&task); err != nil {
facades.Log().Error(err)
return ctx.Response().Json(500, map[string]any{
"error": "Could not save the task.",
})
}
var saved models.Task
err = facades.Orm().WithContext(ctx).Query().With("Creator").With("Assignee").Find(&saved, task.ID)
if err != nil {
facades.Log().Error(err)
return ctx.Response().Json(500, map[string]any{
"error": "Could not load the task.",
})
}
return ctx.Response().Json(201, saved)
}
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 {
- facades.Log().Error(err)
- return ctx.Response().Json(500, map[string]any{
- "error": "Could not load the task.",
+ var task models.Task
+ err := facades.Orm().WithContext(ctx).Query().
+ With("Creator").
+ With("Assignee").
+ FindOrFail(&task, ctx.Request().RouteInt("id"))
+ if errors.Is(err, frameworkerrors.OrmRecordNotFound) {
+ return ctx.Response().Json(404, map[string]any{
+ "error": "The task does not exist.",
})
}
- 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 {
facades.Log().Error(err)
return ctx.Response().Json(500, map[string]any{
"error": "Could not load the task.",
})
}
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",
"per_page": "bail|filled|integer|min:1|max:100",
})
if err != nil {
facades.Log().Error(err)
return ctx.Response().Json(500, map[string]any{
"error": "Could not list the tasks.",
})
}
if validator.Fails() {
return ctx.Response().Json(422, map[string]any{
"errors": validator.Errors().All(),
})
}
page := ctx.Request().QueryInt("page", 1)
perPage := ctx.Request().QueryInt("per_page", 15)
query := facades.Orm().WithContext(ctx).Query()
if status := ctx.Request().Query("status"); status != "" {
query = query.Where("status", status)
}
var tasks []models.Task
var total int64
err = query.With("Creator").With("Assignee").OrderByDesc("id").Paginate(page, perPage, &tasks, &total)
if err != nil {
facades.Log().Error(err)
return ctx.Response().Json(500, map[string]any{
"error": "Could not list the tasks.",
})
}
return ctx.Response().Json(200, map[string]any{
"data": tasks,
"meta": map[string]any{
"page": page,
"per_page": perPage,
"total": total,
},
})
}
```
- A missing task gets "The task does not exist." The check, `errors.Is(err, frameworkerrors.OrmRecordNotFound)`, is the one the auth middleware uses for a deleted user. Any other error is a `500`.
- The id check from [Validation with Form Requests](https://devchalk.com/goravel/08-validation-form-requests#validating-in-the-controller) goes. `RouteInt("abc")` is `0`, and no task has id `0`, so `abc` is a `404` too.
- [`WithContext(ctx)`](https://www.goravel.dev/orm/getting-started#inject-context) passes the request's context to the query, as in [Register & Login with JWT](https://devchalk.com/goravel/09-register-login-jwt#registering).
`users/{id}` gets the same check:
```diff lang="go" title="app/http/controllers/user_controller.go" collapse={15-38} collapse={54-60}
package controllers
import (
+ "errors"
+
"taskflow/app/enums"
"taskflow/app/facades"
"taskflow/app/models"
"github.com/goravel/framework/contracts/database/orm"
"github.com/goravel/framework/contracts/http"
+ frameworkerrors "github.com/goravel/framework/errors"
)
type UserController struct{}
func NewUserController() *UserController {
return &UserController{}
}
func (r *UserController) Index(ctx http.Context) http.Response {
var users []models.User
err := facades.Orm().Query().
With("AssignedTasks", func(query orm.Query) orm.Query {
return query.Where("status <> ?", enums.StatusDone)
}).
With("AssignedTasks.Creator").
Find(&users)
if err != nil {
facades.Log().Error(err)
return ctx.Response().Json(500, map[string]any{
"error": "Could not list the users.",
})
}
return ctx.Response().Json(200, users)
}
func (r *UserController) Show(ctx http.Context) http.Response {
var user models.User
- err := facades.Orm().Query().
+ err := facades.Orm().WithContext(ctx).Query().
With("CreatedTasks").
With("AssignedTasks").
- Find(&user, ctx.Request().RouteInt("id"))
+ FindOrFail(&user, ctx.Request().RouteInt("id"))
+ if errors.Is(err, frameworkerrors.OrmRecordNotFound) {
+ return ctx.Response().Json(404, map[string]any{
+ "error": "The user does not exist.",
+ })
+ }
if err != nil {
facades.Log().Error(err)
return ctx.Response().Json(500, map[string]any{
"error": "Could not load the user.",
})
}
return ctx.Response().Json(200, user)
}
```
```bash t1:missing=/"(The task does not exist[.])"/
$ curl http://localhost:3000/api/v1/tasks/99 -H "Authorization: Bearer $TOKEN"
{"error":"The task does not exist."}
$ curl http://localhost:3000/api/v1/tasks/abc -H "Authorization: Bearer $TOKEN"
{"error":"The task does not exist."}
$ curl http://localhost:3000/api/v1/users/99 -H "Authorization: Bearer $TOKEN"
{"error":"The user does not exist."}
```
## Updating a task
[Routing & Responses](https://devchalk.com/goravel/02-routing-response#controllers) listed `PUT` for `Update`. `PUT` replaces the whole task, and [`PATCH`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods/PATCH) changes only the fields that are sent. Marking a task done sends only the status, so TaskFlow uses `PATCH`. Its rules go in a form request:
```bash
$ go run . artisan make:request UpdateTaskRequest
SUCCESS Request created successfully
```
Fill it in, and delete `Messages`, `PrepareForValidation`, its `validation` import and the sample field:
```go title="app/http/requests/update_task_request.go" t2:sometimes=/(sometimes)/
package requests
import (
"github.com/goravel/framework/contracts/http"
)
type UpdateTaskRequest struct {
}
func (r *UpdateTaskRequest) Authorize(ctx http.Context) error {
return nil
}
func (r *UpdateTaskRequest) Filters(ctx http.Context) map[string]any {
return map[string]any{
"title": "trim",
"description": "trim",
}
}
func (r *UpdateTaskRequest) Rules(ctx http.Context) map[string]any {
return map[string]any{
"title": "sometimes|required|string|max:255",
"description": "sometimes|required|string",
"assigned_to": "bail|sometimes|nullable|integer|exists:users,id|regex:^[0-9]+$",
"due_at": "bail|sometimes|nullable|date|future_date",
"status": "sometimes|required|in:pending,in-progress,done",
}
}
func (r *UpdateTaskRequest) Attributes(ctx http.Context) map[string]string {
return map[string]string{
"assigned_to": "assignee",
"due_at": "due date",
}
}
```
`UpdateTaskRequest` has no fields. A struct can't tell an assignee that wasn't sent from one sent as `null`, so the handler reads what was sent from the validator instead.
- Every rule starts with `sometimes`, so a field the body leaves out skips all its rules.
- `nullable` lets a client send `null` for the assignee or the due date, to clear it.
- The filters and field names match `StoreTaskRequest`.
The handler finds the task first, so a missing task is a `404` before any rule runs:
```diff lang="go" title="app/http/controllers/task_controller.go" collapse={1-11} collapse={17-130} t3:validated=/validator[.](Validated)[(][)]/
package controllers
import (
"errors"
"taskflow/app/enums"
"taskflow/app/facades"
"taskflow/app/http/authuser"
"taskflow/app/http/requests"
"taskflow/app/models"
"github.com/goravel/framework/contracts/http"
frameworkerrors "github.com/goravel/framework/errors"
+ "github.com/goravel/framework/validation"
)
type TaskController struct {
// Dependent services
}
func NewTaskController() *TaskController {
return &TaskController{
// Inject services
}
}
func (r *TaskController) Store(ctx http.Context) http.Response {
var request requests.StoreTaskRequest
validationErrors, err := ctx.Request().ValidateRequest(&request)
if err != nil {
facades.Log().Error(err)
return ctx.Response().Json(500, map[string]any{
"error": "Could not save the task.",
})
}
if validationErrors != nil {
return ctx.Response().Json(422, map[string]any{
"errors": validationErrors.All(),
})
}
task := models.Task{
CreatedBy: authuser.From(ctx).ID,
AssignedTo: request.AssignedTo,
Title: request.Title,
Description: request.Description,
Status: enums.StatusPending,
DueAt: request.DueAt,
}
if err := facades.Orm().WithContext(ctx).Query().Create(&task); err != nil {
facades.Log().Error(err)
return ctx.Response().Json(500, map[string]any{
"error": "Could not save the task.",
})
}
var saved models.Task
err = facades.Orm().WithContext(ctx).Query().With("Creator").With("Assignee").Find(&saved, task.ID)
if err != nil {
facades.Log().Error(err)
return ctx.Response().Json(500, map[string]any{
"error": "Could not load the task.",
})
}
return ctx.Response().Json(201, saved)
}
func (r *TaskController) Show(ctx http.Context) http.Response {
var task models.Task
err := facades.Orm().WithContext(ctx).Query().
With("Creator").
With("Assignee").
FindOrFail(&task, ctx.Request().RouteInt("id"))
if errors.Is(err, frameworkerrors.OrmRecordNotFound) {
return ctx.Response().Json(404, map[string]any{
"error": "The task does not exist.",
})
}
if err != nil {
facades.Log().Error(err)
return ctx.Response().Json(500, map[string]any{
"error": "Could not load the task.",
})
}
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",
"per_page": "bail|filled|integer|min:1|max:100",
})
if err != nil {
facades.Log().Error(err)
return ctx.Response().Json(500, map[string]any{
"error": "Could not list the tasks.",
})
}
if validator.Fails() {
return ctx.Response().Json(422, map[string]any{
"errors": validator.Errors().All(),
})
}
page := ctx.Request().QueryInt("page", 1)
perPage := ctx.Request().QueryInt("per_page", 15)
query := facades.Orm().WithContext(ctx).Query()
if status := ctx.Request().Query("status"); status != "" {
query = query.Where("status", status)
}
var tasks []models.Task
var total int64
err = query.With("Creator").With("Assignee").OrderByDesc("id").Paginate(page, perPage, &tasks, &total)
if err != nil {
facades.Log().Error(err)
return ctx.Response().Json(500, map[string]any{
"error": "Could not list the tasks.",
})
}
return ctx.Response().Json(200, map[string]any{
"data": tasks,
"meta": map[string]any{
"page": page,
"per_page": perPage,
"total": total,
},
})
}
+
+func (r *TaskController) Update(ctx http.Context) http.Response {
+ var task models.Task
+ err := facades.Orm().WithContext(ctx).Query().FindOrFail(&task, ctx.Request().RouteInt("id"))
+ if errors.Is(err, frameworkerrors.OrmRecordNotFound) {
+ return ctx.Response().Json(404, map[string]any{
+ "error": "The task does not exist.",
+ })
+ }
+ if err != nil {
+ facades.Log().Error(err)
+ return ctx.Response().Json(500, map[string]any{
+ "error": "Could not load the task.",
+ })
+ }
+
+ var request requests.UpdateTaskRequest
+ if err := request.Authorize(ctx); err != nil {
+ facades.Log().Error(err)
+ return ctx.Response().Json(500, map[string]any{
+ "error": "Could not save the task.",
+ })
+ }
+
+ validator, err := ctx.Request().Validate(
+ request.Rules(ctx),
+ validation.Filters(request.Filters(ctx)),
+ validation.Attributes(request.Attributes(ctx)),
+ )
+ if err != nil {
+ facades.Log().Error(err)
+ return ctx.Response().Json(500, map[string]any{
+ "error": "Could not save the task.",
+ })
+ }
+ if validator.Fails() {
+ return ctx.Response().Json(422, map[string]any{
+ "errors": validator.Errors().All(),
+ })
+ }
+
+ if changes := validator.Validated(); len(changes) > 0 {
+ if _, err := facades.Orm().WithContext(ctx).Query().Model(&task).Update(changes); err != nil {
+ facades.Log().Error(err)
+ return ctx.Response().Json(500, map[string]any{
+ "error": "Could not save the task.",
+ })
+ }
+ }
+
+ var saved models.Task
+ err = facades.Orm().WithContext(ctx).Query().With("Creator").With("Assignee").Find(&saved, task.ID)
+ if err != nil {
+ facades.Log().Error(err)
+ return ctx.Response().Json(500, map[string]any{
+ "error": "Could not load the task.",
+ })
+ }
+
+ return ctx.Response().Json(200, saved)
+}
```
`Update` runs `Authorize`, then the request's rules, filters and field names with `Validate`. `ValidateRequest` does the same, then fills the struct, and returns only the errors. `Update` needs `Validated()`, so it calls `Validate` itself. If the request later gets `Messages` or `PrepareForValidation`, `Update` has to pass them in too.
- `Validated()` holds the [fields that were sent and passed](https://www.goravel.dev/the-basics/validation#working-with-validated-input), after the filters. `Update(changes)` [writes only those columns](https://www.goravel.dev/orm/getting-started#update-columns), plus `updated_at`.
- Only fields with a rule reach `Validated()`, so a client can't change `created_by` or `id`.
- An empty body changes nothing.
- A field can also come from the query string, as with `ValidateRequest` in [Validation with Form Requests](https://devchalk.com/goravel/08-validation-form-requests#form-requests).
Add the route:
```diff lang="go" title="routes/api.go" collapse={1-26}
package routes
import (
"taskflow/app/facades"
"taskflow/app/http/controllers"
"taskflow/app/http/middleware"
"github.com/goravel/framework/contracts/route"
httpmiddleware "github.com/goravel/framework/http/middleware"
)
func Api() {
authController := controllers.NewAuthController()
taskController := controllers.NewTaskController()
userController := controllers.NewUserController()
facades.Route().Prefix("api/v1").Group(func(router route.Router) {
router.Post("register", authController.Register)
router.Middleware(httpmiddleware.Throttle("login")).Post("login", authController.Login)
router.Post("refresh", authController.Refresh)
router.Middleware(&middleware.Auth{}).Group(func(router route.Router) {
router.Get("me", authController.Me)
router.Post("logout", authController.Logout)
router.Post("tasks", taskController.Store).Name("tasks.create")
router.Get("tasks/{id}", taskController.Show)
router.Get("tasks", taskController.Index)
+ router.Patch("tasks/{id}", taskController.Update)
router.Get("users", userController.Index)
router.Get("users/{id}", userController.Show)
})
})
}
```
Add a request for it to `requests.http`:
```diff lang="http" title="requests.http" collapse={1-64} collapse={82-87}
### Register
POST http://localhost:3000/api/v1/register
Content-Type: application/json
{
"name": "Ben",
"email": "ben@example.com",
"password": "secret123"
}
### Log in
POST http://localhost:3000/api/v1/login
Content-Type: application/json
{
"email": "ben@example.com",
"password": "secret123"
}
> {%
client.global.set("token", response.body.token);
%}
### Me
GET http://localhost:3000/api/v1/me
Authorization: Bearer {{token}}
### Refresh
POST http://localhost:3000/api/v1/refresh
Authorization: Bearer {{token}}
> {%
client.global.set("token", response.body.token);
%}
### Log out
POST http://localhost:3000/api/v1/logout
Authorization: Bearer {{token}}
### Create task
POST http://localhost:3000/api/v1/tasks
Authorization: Bearer {{token}}
Content-Type: application/json
{
"title": "Ship v1",
"description": "The first release"
}
### Create task with bad data
POST http://localhost:3000/api/v1/tasks
Authorization: Bearer {{token}}
Content-Type: application/json
{
"title": " ",
"assigned_to": 99,
"due_at": "2020-01-01"
}
### Show task
GET http://localhost:3000/api/v1/tasks/1
Authorization: Bearer {{token}}
### List tasks
GET http://localhost:3000/api/v1/tasks?status=pending&per_page=5
Authorization: Bearer {{token}}
+### Update task
+PATCH http://localhost:3000/api/v1/tasks/1
+Authorization: Bearer {{token}}
+Content-Type: application/json
+
+{
+ "status": "in-progress"
+}
+
### List users
GET http://localhost:3000/api/v1/users
Authorization: Bearer {{token}}
### Show user
GET http://localhost:3000/api/v1/users/2
Authorization: Bearer {{token}}
### Hello
GET http://localhost:3000/hello
```
Mark the new task as in progress:
```bash
$ curl -s -o /dev/null -w "%{http_code}\n" \
-X PATCH http://localhost:3000/api/v1/tasks/8 \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"status":"in-progress"}'
200
```
The `UPDATE` sets only the status:
```text wrap t3:validated=/SET (.+?),"updated_at"/
[2026-10-24 20:52:07.441] local.info: [2.634ms] [rows:1] UPDATE "tasks" SET "status"='in-progress',"updated_at"='2026-10-24 20:52:07.438' WHERE "id" = 8
```
Rename it with spaces around the title, and clear the assignee:
```bash
$ curl -s -o /dev/null -w "%{http_code}\n" \
-X PATCH http://localhost:3000/api/v1/tasks/8 \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"title":" Ship v2.0 ","assigned_to":null}'
200
```
```text wrap t3:validated=/SET (.+?),"updated_at"/
[2026-10-24 20:52:08.48] local.info: [0.875ms] [rows:1] UPDATE "tasks" SET "assigned_to"=NULL,"title"='Ship v2.0',"updated_at"='2026-10-24 20:52:08.479' WHERE "id" = 8
```
The title was trimmed before it was saved, and `null` cleared the assignee.
Try to make user 3 the creator while marking the task done:
```bash
$ curl -s -o /dev/null -w "%{http_code}\n" \
-X PATCH http://localhost:3000/api/v1/tasks/8 \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"status":"done","created_by":3}'
200
```
```text wrap t3:validated=/SET (.+?),"updated_at"/
[2026-10-24 20:52:09.512] local.info: [0.699ms] [rows:1] UPDATE "tasks" SET "status"='done',"updated_at"='2026-10-24 20:52:09.511' WHERE "id" = 8
```
`created_by` has no rule, so only the status changed.
An empty body answers `200` with the task unchanged, and the log has no `UPDATE`:
```bash
$ curl -s -o /dev/null -w "%{http_code}\n" \
-X PATCH http://localhost:3000/api/v1/tasks/8 \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{}'
200
```
```text wrap
[2026-10-24 20:52:10.557] local.info: [0.441ms] [rows:1] SELECT * FROM "tasks" WHERE "tasks"."id" = 8
[2026-10-24 20:52:10.558] local.info: [0.309ms] [rows:1] SELECT * FROM "users" WHERE "users"."id" = 1
[2026-10-24 20:52:10.558] local.info: [0.762ms] [rows:1] SELECT * FROM "tasks" WHERE "tasks"."id" = 8
```
Bad values get a `422`, and a task that doesn't exist gets a `404`:
```bash t1:missing=/"(The task does not exist[.])"/
$ curl -X PATCH http://localhost:3000/api/v1/tasks/8 \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"title":" ","status":"finished","assigned_to":99}'
{"errors":{"assigned_to":{"exists":"The assignee does not exist."},"status":{"in":"The selected status is invalid."},"title":{"required":"The title field is required."}}}
$ curl -X PATCH http://localhost:3000/api/v1/tasks/99 \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"status":"done"}'
{"error":"The task does not exist."}
```
## Deleting a task
[Deleting](https://www.goravel.dev/orm/getting-started#delete) a row that doesn't exist is not an error in Goravel. It just deletes nothing. So `Destroy` looks the task up first with the same `FindOrFail` check as `Show`:
```diff lang="go" title="app/http/controllers/task_controller.go" collapse={1-193} t2:nocontent=/Response[(][)][.](NoContent)/
package controllers
import (
"errors"
"taskflow/app/enums"
"taskflow/app/facades"
"taskflow/app/http/authuser"
"taskflow/app/http/requests"
"taskflow/app/models"
"github.com/goravel/framework/contracts/http"
frameworkerrors "github.com/goravel/framework/errors"
"github.com/goravel/framework/validation"
)
type TaskController struct {
// Dependent services
}
func NewTaskController() *TaskController {
return &TaskController{
// Inject services
}
}
func (r *TaskController) Store(ctx http.Context) http.Response {
var request requests.StoreTaskRequest
validationErrors, err := ctx.Request().ValidateRequest(&request)
if err != nil {
facades.Log().Error(err)
return ctx.Response().Json(500, map[string]any{
"error": "Could not save the task.",
})
}
if validationErrors != nil {
return ctx.Response().Json(422, map[string]any{
"errors": validationErrors.All(),
})
}
task := models.Task{
CreatedBy: authuser.From(ctx).ID,
AssignedTo: request.AssignedTo,
Title: request.Title,
Description: request.Description,
Status: enums.StatusPending,
DueAt: request.DueAt,
}
if err := facades.Orm().WithContext(ctx).Query().Create(&task); err != nil {
facades.Log().Error(err)
return ctx.Response().Json(500, map[string]any{
"error": "Could not save the task.",
})
}
var saved models.Task
err = facades.Orm().WithContext(ctx).Query().With("Creator").With("Assignee").Find(&saved, task.ID)
if err != nil {
facades.Log().Error(err)
return ctx.Response().Json(500, map[string]any{
"error": "Could not load the task.",
})
}
return ctx.Response().Json(201, saved)
}
func (r *TaskController) Show(ctx http.Context) http.Response {
var task models.Task
err := facades.Orm().WithContext(ctx).Query().
With("Creator").
With("Assignee").
FindOrFail(&task, ctx.Request().RouteInt("id"))
if errors.Is(err, frameworkerrors.OrmRecordNotFound) {
return ctx.Response().Json(404, map[string]any{
"error": "The task does not exist.",
})
}
if err != nil {
facades.Log().Error(err)
return ctx.Response().Json(500, map[string]any{
"error": "Could not load the task.",
})
}
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",
"per_page": "bail|filled|integer|min:1|max:100",
})
if err != nil {
facades.Log().Error(err)
return ctx.Response().Json(500, map[string]any{
"error": "Could not list the tasks.",
})
}
if validator.Fails() {
return ctx.Response().Json(422, map[string]any{
"errors": validator.Errors().All(),
})
}
page := ctx.Request().QueryInt("page", 1)
perPage := ctx.Request().QueryInt("per_page", 15)
query := facades.Orm().WithContext(ctx).Query()
if status := ctx.Request().Query("status"); status != "" {
query = query.Where("status", status)
}
var tasks []models.Task
var total int64
err = query.With("Creator").With("Assignee").OrderByDesc("id").Paginate(page, perPage, &tasks, &total)
if err != nil {
facades.Log().Error(err)
return ctx.Response().Json(500, map[string]any{
"error": "Could not list the tasks.",
})
}
return ctx.Response().Json(200, map[string]any{
"data": tasks,
"meta": map[string]any{
"page": page,
"per_page": perPage,
"total": total,
},
})
}
func (r *TaskController) Update(ctx http.Context) http.Response {
var task models.Task
err := facades.Orm().WithContext(ctx).Query().FindOrFail(&task, ctx.Request().RouteInt("id"))
if errors.Is(err, frameworkerrors.OrmRecordNotFound) {
return ctx.Response().Json(404, map[string]any{
"error": "The task does not exist.",
})
}
if err != nil {
facades.Log().Error(err)
return ctx.Response().Json(500, map[string]any{
"error": "Could not load the task.",
})
}
var request requests.UpdateTaskRequest
if err := request.Authorize(ctx); err != nil {
facades.Log().Error(err)
return ctx.Response().Json(500, map[string]any{
"error": "Could not save the task.",
})
}
validator, err := ctx.Request().Validate(
request.Rules(ctx),
validation.Filters(request.Filters(ctx)),
validation.Attributes(request.Attributes(ctx)),
)
if err != nil {
facades.Log().Error(err)
return ctx.Response().Json(500, map[string]any{
"error": "Could not save the task.",
})
}
if validator.Fails() {
return ctx.Response().Json(422, map[string]any{
"errors": validator.Errors().All(),
})
}
if changes := validator.Validated(); len(changes) > 0 {
if _, err := facades.Orm().WithContext(ctx).Query().Model(&task).Update(changes); err != nil {
facades.Log().Error(err)
return ctx.Response().Json(500, map[string]any{
"error": "Could not save the task.",
})
}
}
var saved models.Task
err = facades.Orm().WithContext(ctx).Query().With("Creator").With("Assignee").Find(&saved, task.ID)
if err != nil {
facades.Log().Error(err)
return ctx.Response().Json(500, map[string]any{
"error": "Could not load the task.",
})
}
return ctx.Response().Json(200, saved)
}
+
+func (r *TaskController) Destroy(ctx http.Context) http.Response {
+ var task models.Task
+ err := facades.Orm().WithContext(ctx).Query().FindOrFail(&task, ctx.Request().RouteInt("id"))
+ if errors.Is(err, frameworkerrors.OrmRecordNotFound) {
+ return ctx.Response().Json(404, map[string]any{
+ "error": "The task does not exist.",
+ })
+ }
+ if err != nil {
+ facades.Log().Error(err)
+ return ctx.Response().Json(500, map[string]any{
+ "error": "Could not load the task.",
+ })
+ }
+
+ if _, err := facades.Orm().WithContext(ctx).Query().Delete(&task); err != nil {
+ facades.Log().Error(err)
+ return ctx.Response().Json(500, map[string]any{
+ "error": "Could not delete the task.",
+ })
+ }
+
+ return ctx.Response().NoContent()
+}
```
`NoContent()` answers [`204`](https://www.goravel.dev/the-basics/response#no-content), as `Logout` does. Add the route and a request:
```diff lang="go" title="routes/api.go" collapse={1-27}
package routes
import (
"taskflow/app/facades"
"taskflow/app/http/controllers"
"taskflow/app/http/middleware"
"github.com/goravel/framework/contracts/route"
httpmiddleware "github.com/goravel/framework/http/middleware"
)
func Api() {
authController := controllers.NewAuthController()
taskController := controllers.NewTaskController()
userController := controllers.NewUserController()
facades.Route().Prefix("api/v1").Group(func(router route.Router) {
router.Post("register", authController.Register)
router.Middleware(httpmiddleware.Throttle("login")).Post("login", authController.Login)
router.Post("refresh", authController.Refresh)
router.Middleware(&middleware.Auth{}).Group(func(router route.Router) {
router.Get("me", authController.Me)
router.Post("logout", authController.Logout)
router.Post("tasks", taskController.Store).Name("tasks.create")
router.Get("tasks/{id}", taskController.Show)
router.Get("tasks", taskController.Index)
router.Patch("tasks/{id}", taskController.Update)
+ router.Delete("tasks/{id}", taskController.Destroy)
router.Get("users", userController.Index)
router.Get("users/{id}", userController.Show)
})
})
}
```
```diff lang="http" title="requests.http" collapse={1-73} collapse={86-91}
### Register
POST http://localhost:3000/api/v1/register
Content-Type: application/json
{
"name": "Ben",
"email": "ben@example.com",
"password": "secret123"
}
### Log in
POST http://localhost:3000/api/v1/login
Content-Type: application/json
{
"email": "ben@example.com",
"password": "secret123"
}
> {%
client.global.set("token", response.body.token);
%}
### Me
GET http://localhost:3000/api/v1/me
Authorization: Bearer {{token}}
### Refresh
POST http://localhost:3000/api/v1/refresh
Authorization: Bearer {{token}}
> {%
client.global.set("token", response.body.token);
%}
### Log out
POST http://localhost:3000/api/v1/logout
Authorization: Bearer {{token}}
### Create task
POST http://localhost:3000/api/v1/tasks
Authorization: Bearer {{token}}
Content-Type: application/json
{
"title": "Ship v1",
"description": "The first release"
}
### Create task with bad data
POST http://localhost:3000/api/v1/tasks
Authorization: Bearer {{token}}
Content-Type: application/json
{
"title": " ",
"assigned_to": 99,
"due_at": "2020-01-01"
}
### Show task
GET http://localhost:3000/api/v1/tasks/1
Authorization: Bearer {{token}}
### List tasks
GET http://localhost:3000/api/v1/tasks?status=pending&per_page=5
Authorization: Bearer {{token}}
### Update task
PATCH http://localhost:3000/api/v1/tasks/1
Authorization: Bearer {{token}}
Content-Type: application/json
{
"status": "in-progress"
}
+### Delete task
+DELETE http://localhost:3000/api/v1/tasks/1
+Authorization: Bearer {{token}}
+
### List users
GET http://localhost:3000/api/v1/users
Authorization: Bearer {{token}}
### Show user
GET http://localhost:3000/api/v1/users/2
Authorization: Bearer {{token}}
### Hello
GET http://localhost:3000/hello
```
Delete the new task:
```bash t2:nocontent=/HTTP[/]1[.]1 (204 No Content)/
$ curl -i -X DELETE http://localhost:3000/api/v1/tasks/8 -H "Authorization: Bearer $TOKEN"
HTTP/1.1 204 No Content
```
```text wrap
[2026-10-24 20:52:16.782] local.info: [1.450ms] [rows:1] SELECT * FROM "tasks" WHERE "tasks"."id" = 8
[2026-10-24 20:52:16.784] local.info: [1.675ms] [rows:1] DELETE FROM "tasks" WHERE "tasks"."id" = 8
```
It is gone, so both a second delete and a read get a `404`:
```bash t1:missing=/"(The task does not exist[.])"/
$ curl -X DELETE http://localhost:3000/api/v1/tasks/8 -H "Authorization: Bearer $TOKEN"
{"error":"The task does not exist."}
$ curl http://localhost:3000/api/v1/tasks/8 -H "Authorization: Bearer $TOKEN"
{"error":"The task does not exist."}
```
Soft deletes:
TaskFlow removes the row. With [soft deletes](https://www.goravel.dev/orm/getting-started#delete), a model embeds `orm.SoftDeletes` and the migration adds a `deleted_at` column. `Delete` then only fills in `deleted_at`, and queries skip those rows. `WithTrashed` includes them, `Restore` brings one back, and `ForceDelete` removes it for good.
TaskFlow's tasks can now be listed, created, read, changed and deleted, by anyone who is logged in. A member can still change or delete a manager's task.
---
Previous: [10. Auth Middleware & Rate Limits](https://devchalk.com/goravel/10-auth-middleware.md)
Next: [12. Gates & Policies](https://devchalk.com/goravel/12-gates-policies.md)
Series: [Goravel](https://devchalk.com/goravel.md)
# Gates & Policies
> Managers create and assign, members update status.
Goravel series, part 12 of 20. By Krishan Kumar. Published 2026-10-25.
Source: https://devchalk.com/goravel/12-gates-policies
Code: https://github.com/krishankumar01/goravel-taskflow/tree/part-12
In [Task CRUD](https://devchalk.com/goravel/11-task-crud), any signed-in user can do everything, even delete a task a manager planned. In this part only managers create, assign and delete tasks, and members can only change the status of their own. Anything else gets a [`403`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/403) that says why.
Start from the Task CRUD code:
```bash
git clone https://github.com/krishankumar01/goravel-taskflow
cd goravel-taskflow
git checkout part-11
cp .env.example .env
go run . artisan key:generate
go run . artisan jwt:secret
docker compose up -d
go run . artisan migrate:fresh --seed
go install github.com/air-verse/air@latest
air
```
## The rules
Who can do what:
| | manager | member |
|---|---|---|
| read tasks | yes | yes |
| create or delete tasks | yes | no |
| change a task's details or assignee | yes | no |
| change a task's status | yes | own tasks only |
| list or look up users | yes | no |
Goravel checks permissions with [gates](https://www.goravel.dev/security/authorization#gates) and [policies](https://www.goravel.dev/security/authorization#policies). A gate is a function with a name, like `view-users`, that answers yes or no. A policy is a struct that groups those functions for one model, like `TaskPolicy` with `Create`, `Update` and `Delete`. The users list needs a single check, so a gate is enough. Tasks need one per action, so they get a policy.
Testing the rules needs a manager and a member. Ana is the manager from the seeders.
1. Reset the database with `migrate:fresh --seed`.
2. Register Ben. New accounts are members, and Ben gets id 5.
3. Log in as Ana and as Ben, and save each token in a variable, `ANA` and `BEN`.
```bash
$ go run . artisan migrate:fresh --seed
SUCCESS Dropped all tables successfully
INFO Running migration
INFO Running: 20210101000002_create_jobs_table
INFO Running: 20261022221906_create_users_table
INFO Running: 20261023063944_create_tasks_table
SUCCESS Migration success
SUCCESS Database seeding completed successfully.
SUCCESS Migration fresh success
$ curl -X POST http://localhost:3000/api/v1/register \
-H "Content-Type: application/json" \
-d '{"name":"Ben","email":"ben@example.com","password":"secret123"}'
{"created_at":"2026-10-25 10:43:01","updated_at":"2026-10-25 10:43:01","id":5,"name":"Ben","email":"ben@example.com","role":"member","created_tasks":null,"assigned_tasks":null}
$ curl -X POST http://localhost:3000/api/v1/login \
-H "Content-Type: application/json" \
-d '{"email":"ana@example.com","password":"password"}'
{"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9…"}
$ ANA=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9…
$ curl -X POST http://localhost:3000/api/v1/login \
-H "Content-Type: application/json" \
-d '{"email":"ben@example.com","password":"secret123"}'
{"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9…"}
$ BEN=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9…
```
## A gate for the users list
Members don't assign tasks, so they have no use for the users list. Only managers should see it.
Each gate is checked by its name. If a handler misspells the name, Goravel doesn't report an error. It finds no gate with that name and refuses the request. To catch that mistake when the code compiles, the names are constants:
```go title="app/policies/abilities.go" t1:viewusers=/(ViewUsers)/
package policies
const (
ViewUsers = "view-users"
)
```
Register the gate in `bootstrap/app.go`, next to the login limit from [Auth Middleware & Rate Limits](https://devchalk.com/goravel/10-auth-middleware#limiting-login-attempts):
```diff lang="go" title="bootstrap/app.go" collapse={22-44} t1:viewusers=/policies[.](ViewUsers)/ t2:denied=/"(Only managers can see the users[.])"/
package bootstrap
import (
+ "context"
"strings"
+ "github.com/goravel/framework/auth/access"
+ contractsaccess "github.com/goravel/framework/contracts/auth/access"
"github.com/goravel/framework/contracts/database/schema"
contractsfoundation "github.com/goravel/framework/contracts/foundation"
"github.com/goravel/framework/contracts/http"
"github.com/goravel/framework/foundation"
"github.com/goravel/framework/http/limit"
+ "taskflow/app/enums"
"taskflow/app/facades"
+ "taskflow/app/http/authuser"
"taskflow/app/models"
+ "taskflow/app/policies"
"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{}},
})
facades.RateLimiter().For("login", func(ctx http.Context) http.Limit {
email := strings.ToLower(strings.TrimSpace(ctx.Request().Input("email")))
return limit.PerMinute(5).By(email).Response(func(ctx http.Context) {
ctx.Response().Json(429, map[string]any{
"error": "Too many login attempts. Try again in a minute.",
}).Abort()
})
})
+
+ facades.Gate().Define(policies.ViewUsers, func(ctx context.Context, arguments map[string]any) contractsaccess.Response {
+ if user := authuser.From(ctx); user != nil && user.Role == enums.RoleManager {
+ return access.NewAllowResponse()
+ }
+
+ return access.NewDenyResponse("Only managers can see the users.")
+ })
}).
Create()
}
```
- [`Define`](https://www.goravel.dev/security/authorization#writing-gates) takes the name and a function. The function gets a context and a map of arguments, and returns an answer.
- `authuser.From(ctx)` is the signed-in user, the one the middleware loaded in [Auth Middleware & Rate Limits](https://devchalk.com/goravel/10-auth-middleware#passing-the-user-to-handlers).
- `access.NewAllowResponse()` allows. `access.NewDenyResponse` denies, with a message for the client, like "Only managers can see the users."
Registering a gate doesn't protect a route by itself. A handler has to ask the gate, so both user handlers ask it before reading anything:
```diff lang="go" title="app/http/controllers/user_controller.go" collapse={1-6} collapse={12-20} collapse={34-45} collapse={56-72} t1:viewusers=/policies[.](ViewUsers)/
package controllers
import (
"errors"
"taskflow/app/enums"
"taskflow/app/facades"
"taskflow/app/models"
+ "taskflow/app/policies"
"github.com/goravel/framework/contracts/database/orm"
"github.com/goravel/framework/contracts/http"
frameworkerrors "github.com/goravel/framework/errors"
)
type UserController struct{}
func NewUserController() *UserController {
return &UserController{}
}
func (r *UserController) Index(ctx http.Context) http.Response {
+ if response := facades.Gate().WithContext(ctx).Inspect(policies.ViewUsers, nil); !response.Allowed() {
+ return ctx.Response().Json(403, map[string]any{
+ "error": response.Message(),
+ })
+ }
+
var users []models.User
- err := facades.Orm().Query().
+ err := facades.Orm().WithContext(ctx).Query().
With("AssignedTasks", func(query orm.Query) orm.Query {
return query.Where("status <> ?", enums.StatusDone)
}).
With("AssignedTasks.Creator").
Find(&users)
if err != nil {
facades.Log().Error(err)
return ctx.Response().Json(500, map[string]any{
"error": "Could not list the users.",
})
}
return ctx.Response().Json(200, users)
}
func (r *UserController) Show(ctx http.Context) http.Response {
+ if response := facades.Gate().WithContext(ctx).Inspect(policies.ViewUsers, nil); !response.Allowed() {
+ return ctx.Response().Json(403, map[string]any{
+ "error": response.Message(),
+ })
+ }
+
var user models.User
err := facades.Orm().WithContext(ctx).Query().
With("CreatedTasks").
With("AssignedTasks").
FindOrFail(&user, ctx.Request().RouteInt("id"))
if errors.Is(err, frameworkerrors.OrmRecordNotFound) {
return ctx.Response().Json(404, map[string]any{
"error": "The user does not exist.",
})
}
if err != nil {
facades.Log().Error(err)
return ctx.Response().Json(500, map[string]any{
"error": "Could not load the user.",
})
}
return ctx.Response().Json(200, user)
}
```
- [`Inspect`](https://www.goravel.dev/security/authorization#gate-responses) runs the gate named `ViewUsers` and returns its answer. `Allowed()` says yes or no, and `Message()` gives the reason. `Allows` would only give the yes or no, and the `403` needs the reason too.
- [`WithContext(ctx)`](https://www.goravel.dev/security/authorization#inject-context) passes the request's context to the gate, so `authuser.From` can find the signed-in user.
- A refusal answers `403` with that reason.
- Separately, `Index`'s query now passes `WithContext(ctx)` too, as `Show`'s already did.
Ana gets the list. Ben is refused with the reason, though he can still read his own account at `/me`:
```bash t2:denied=/"(Only managers can see the users[.])"/
$ curl -s -o /dev/null -w "%{http_code}\n" http://localhost:3000/api/v1/users \
-H "Authorization: Bearer $ANA"
200
$ curl http://localhost:3000/api/v1/users -H "Authorization: Bearer $BEN"
{"error":"Only managers can see the users."}
```
> **Gotcha** If a handler calls the gate without `WithContext(ctx)`, the gate can't see who is signed in. `authuser.From` finds no user, so the gate refuses everyone, even Ana, the manager:
>
> ```bash t2:denied=/"(Only managers can see the users[.])"/
> $ curl http://localhost:3000/api/v1/users -H "Authorization: Bearer $ANA"
> {"error":"Only managers can see the users."}
> ```
>
> So when a manager is refused for no clear reason, check that the call passes `WithContext(ctx)`.
> **Gotcha** If a handler writes the name as a plain string instead of the constant, a typo isn't caught when the code compiles or when the app starts. It only shows up when a request comes in. Here the handler asks for `"view-user"`, one letter short, and Goravel refuses even Ana:
>
> ```bash
> $ curl http://localhost:3000/api/v1/users -H "Authorization: Bearer $ANA"
> {"error":"ability doesn't exist: view-user"}
> ```
>
> The message names the missing gate, but it arrives as a `403`, so it looks like a permission problem rather than a bug.
## Letting managers through every check
Every rule in TaskFlow lets a manager do anything. Instead of checking for a manager in every gate and policy method, [`Before`](https://www.goravel.dev/security/authorization#intercepting-gate-checks) checks it once, for every ability:
```diff lang="go" title="bootstrap/app.go" collapse={1-44} t2:before=/Gate[(][)][.](Before)[(]/
package bootstrap
import (
"context"
"strings"
"github.com/goravel/framework/auth/access"
contractsaccess "github.com/goravel/framework/contracts/auth/access"
"github.com/goravel/framework/contracts/database/schema"
contractsfoundation "github.com/goravel/framework/contracts/foundation"
"github.com/goravel/framework/contracts/http"
"github.com/goravel/framework/foundation"
"github.com/goravel/framework/http/limit"
"taskflow/app/enums"
"taskflow/app/facades"
"taskflow/app/http/authuser"
"taskflow/app/models"
"taskflow/app/policies"
"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{}},
})
facades.RateLimiter().For("login", func(ctx http.Context) http.Limit {
email := strings.ToLower(strings.TrimSpace(ctx.Request().Input("email")))
return limit.PerMinute(5).By(email).Response(func(ctx http.Context) {
ctx.Response().Json(429, map[string]any{
"error": "Too many login attempts. Try again in a minute.",
}).Abort()
})
})
- facades.Gate().Define(policies.ViewUsers, func(ctx context.Context, arguments map[string]any) contractsaccess.Response {
+ facades.Gate().Before(func(ctx context.Context, ability string, arguments map[string]any) contractsaccess.Response {
if user := authuser.From(ctx); user != nil && user.Role == enums.RoleManager {
return access.NewAllowResponse()
}
+ return nil
+ })
+
+ facades.Gate().Define(policies.ViewUsers, func(ctx context.Context, arguments map[string]any) contractsaccess.Response {
return access.NewDenyResponse("Only managers can see the users.")
})
}).
Create()
}
```
`Before` runs before every check. If it returns a response, that response is the answer, and the gate never runs. If it returns `nil`, the gate decides. Here it allows managers and returns `nil` for everyone else, so the users gate only has to refuse members.
## A policy for tasks
Tasks need three rules, one each for creating, changing and deleting. A policy keeps them together in one struct, and [`make:policy`](https://www.goravel.dev/security/authorization#generating-policies) starts it:
```bash
$ go run . artisan make:policy TaskPolicy
SUCCESS Policy created successfully
```
It writes `app/policies/task_policy.go`:
```go title="app/policies/task_policy.go"
package policies
import (
"context"
"github.com/goravel/framework/contracts/auth/access"
)
type TaskPolicy struct {
}
func NewTaskPolicy() *TaskPolicy {
return &TaskPolicy{}
}
func (r *TaskPolicy) Create(ctx context.Context, arguments map[string]any) access.Response {
return nil
}
```
Creating and deleting are the two actions only managers may take, so both get a method now. Fill in the generated `Create`, and add `Delete`. Each only refuses, with a message. Managers never reach them, because `Before` has already let them through. Changing a task is different, since members may change the status, so `Update` gets its own rule afterwards:
```diff lang="go" title="app/policies/task_policy.go" t1:create=/[*]TaskPolicy[)] (Create)[(]/ t3:createdeny=/"(Only managers can create tasks[.])"/
package policies
import (
"context"
-
- "github.com/goravel/framework/contracts/auth/access"
+
+ "github.com/goravel/framework/auth/access"
+ contractsaccess "github.com/goravel/framework/contracts/auth/access"
)
type TaskPolicy struct {
}
func NewTaskPolicy() *TaskPolicy {
return &TaskPolicy{}
}
-func (r *TaskPolicy) Create(ctx context.Context, arguments map[string]any) access.Response {
- return nil
+func (r *TaskPolicy) Create(ctx context.Context, arguments map[string]any) contractsaccess.Response {
+ return access.NewDenyResponse("Only managers can create tasks.")
}
+
+func (r *TaskPolicy) Delete(ctx context.Context, arguments map[string]any) contractsaccess.Response {
+ return access.NewDenyResponse("Only managers can delete tasks.")
+}
```
The file now uses two packages called `access`, so the contracts one is imported as `contractsaccess`, as in `bootstrap/app.go`.
Name the two new abilities:
```diff lang="go" title="app/policies/abilities.go"
package policies
const (
- ViewUsers = "view-users"
+ ViewUsers = "view-users"
+ CreateTask = "create-task"
+ DeleteTask = "delete-task"
)
```
In `bootstrap/app.go`, register the policy methods:
```diff lang="go" title="bootstrap/app.go" collapse={1-58} t1:create=/taskPolicy[.](Create)/
package bootstrap
import (
"context"
"strings"
"github.com/goravel/framework/auth/access"
contractsaccess "github.com/goravel/framework/contracts/auth/access"
"github.com/goravel/framework/contracts/database/schema"
contractsfoundation "github.com/goravel/framework/contracts/foundation"
"github.com/goravel/framework/contracts/http"
"github.com/goravel/framework/foundation"
"github.com/goravel/framework/http/limit"
"taskflow/app/enums"
"taskflow/app/facades"
"taskflow/app/http/authuser"
"taskflow/app/models"
"taskflow/app/policies"
"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{}},
})
facades.RateLimiter().For("login", func(ctx http.Context) http.Limit {
email := strings.ToLower(strings.TrimSpace(ctx.Request().Input("email")))
return limit.PerMinute(5).By(email).Response(func(ctx http.Context) {
ctx.Response().Json(429, map[string]any{
"error": "Too many login attempts. Try again in a minute.",
}).Abort()
})
})
facades.Gate().Before(func(ctx context.Context, ability string, arguments map[string]any) contractsaccess.Response {
if user := authuser.From(ctx); user != nil && user.Role == enums.RoleManager {
return access.NewAllowResponse()
}
return nil
})
facades.Gate().Define(policies.ViewUsers, func(ctx context.Context, arguments map[string]any) contractsaccess.Response {
return access.NewDenyResponse("Only managers can see the users.")
})
+
+ taskPolicy := policies.NewTaskPolicy()
+ facades.Gate().Define(policies.CreateTask, taskPolicy.Create)
+ facades.Gate().Define(policies.DeleteTask, taskPolicy.Delete)
}).
Create()
}
```
- A policy method has the same signature as a gate function, so `taskPolicy.Create` and `taskPolicy.Delete` go straight into `Define`.
`Store` and `Destroy` check the abilities before they do anything:
```diff lang="go" title="app/http/controllers/task_controller.go" collapse={1-8} collapse={14-26} collapse={37-214} collapse={227-233}
package controllers
import (
"errors"
"taskflow/app/enums"
"taskflow/app/facades"
"taskflow/app/http/authuser"
"taskflow/app/http/requests"
"taskflow/app/models"
+ "taskflow/app/policies"
"github.com/goravel/framework/contracts/http"
frameworkerrors "github.com/goravel/framework/errors"
"github.com/goravel/framework/validation"
)
type TaskController struct {
// Dependent services
}
func NewTaskController() *TaskController {
return &TaskController{
// Inject services
}
}
func (r *TaskController) Store(ctx http.Context) http.Response {
+ if response := facades.Gate().WithContext(ctx).Inspect(policies.CreateTask, nil); !response.Allowed() {
+ return ctx.Response().Json(403, map[string]any{
+ "error": response.Message(),
+ })
+ }
+
var request requests.StoreTaskRequest
validationErrors, err := ctx.Request().ValidateRequest(&request)
if err != nil {
facades.Log().Error(err)
return ctx.Response().Json(500, map[string]any{
"error": "Could not save the task.",
})
}
if validationErrors != nil {
return ctx.Response().Json(422, map[string]any{
"errors": validationErrors.All(),
})
}
task := models.Task{
CreatedBy: authuser.From(ctx).ID,
AssignedTo: request.AssignedTo,
Title: request.Title,
Description: request.Description,
Status: enums.StatusPending,
DueAt: request.DueAt,
}
if err := facades.Orm().WithContext(ctx).Query().Create(&task); err != nil {
facades.Log().Error(err)
return ctx.Response().Json(500, map[string]any{
"error": "Could not save the task.",
})
}
var saved models.Task
err = facades.Orm().WithContext(ctx).Query().With("Creator").With("Assignee").Find(&saved, task.ID)
if err != nil {
facades.Log().Error(err)
return ctx.Response().Json(500, map[string]any{
"error": "Could not load the task.",
})
}
return ctx.Response().Json(201, saved)
}
func (r *TaskController) Show(ctx http.Context) http.Response {
var task models.Task
err := facades.Orm().WithContext(ctx).Query().
With("Creator").
With("Assignee").
FindOrFail(&task, ctx.Request().RouteInt("id"))
if errors.Is(err, frameworkerrors.OrmRecordNotFound) {
return ctx.Response().Json(404, map[string]any{
"error": "The task does not exist.",
})
}
if err != nil {
facades.Log().Error(err)
return ctx.Response().Json(500, map[string]any{
"error": "Could not load the task.",
})
}
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",
"per_page": "bail|filled|integer|min:1|max:100",
})
if err != nil {
facades.Log().Error(err)
return ctx.Response().Json(500, map[string]any{
"error": "Could not list the tasks.",
})
}
if validator.Fails() {
return ctx.Response().Json(422, map[string]any{
"errors": validator.Errors().All(),
})
}
page := ctx.Request().QueryInt("page", 1)
perPage := ctx.Request().QueryInt("per_page", 15)
query := facades.Orm().WithContext(ctx).Query()
if status := ctx.Request().Query("status"); status != "" {
query = query.Where("status", status)
}
var tasks []models.Task
var total int64
err = query.With("Creator").With("Assignee").OrderByDesc("id").Paginate(page, perPage, &tasks, &total)
if err != nil {
facades.Log().Error(err)
return ctx.Response().Json(500, map[string]any{
"error": "Could not list the tasks.",
})
}
return ctx.Response().Json(200, map[string]any{
"data": tasks,
"meta": map[string]any{
"page": page,
"per_page": perPage,
"total": total,
},
})
}
func (r *TaskController) Update(ctx http.Context) http.Response {
var task models.Task
err := facades.Orm().WithContext(ctx).Query().FindOrFail(&task, ctx.Request().RouteInt("id"))
if errors.Is(err, frameworkerrors.OrmRecordNotFound) {
return ctx.Response().Json(404, map[string]any{
"error": "The task does not exist.",
})
}
if err != nil {
facades.Log().Error(err)
return ctx.Response().Json(500, map[string]any{
"error": "Could not load the task.",
})
}
var request requests.UpdateTaskRequest
if err := request.Authorize(ctx); err != nil {
facades.Log().Error(err)
return ctx.Response().Json(500, map[string]any{
"error": "Could not save the task.",
})
}
validator, err := ctx.Request().Validate(
request.Rules(ctx),
validation.Filters(request.Filters(ctx)),
validation.Attributes(request.Attributes(ctx)),
)
if err != nil {
facades.Log().Error(err)
return ctx.Response().Json(500, map[string]any{
"error": "Could not save the task.",
})
}
if validator.Fails() {
return ctx.Response().Json(422, map[string]any{
"errors": validator.Errors().All(),
})
}
if changes := validator.Validated(); len(changes) > 0 {
if _, err := facades.Orm().WithContext(ctx).Query().Model(&task).Update(changes); err != nil {
facades.Log().Error(err)
return ctx.Response().Json(500, map[string]any{
"error": "Could not save the task.",
})
}
}
var saved models.Task
err = facades.Orm().WithContext(ctx).Query().With("Creator").With("Assignee").Find(&saved, task.ID)
if err != nil {
facades.Log().Error(err)
return ctx.Response().Json(500, map[string]any{
"error": "Could not load the task.",
})
}
return ctx.Response().Json(200, saved)
}
func (r *TaskController) Destroy(ctx http.Context) http.Response {
var task models.Task
err := facades.Orm().WithContext(ctx).Query().FindOrFail(&task, ctx.Request().RouteInt("id"))
if errors.Is(err, frameworkerrors.OrmRecordNotFound) {
return ctx.Response().Json(404, map[string]any{
"error": "The task does not exist.",
})
}
if err != nil {
facades.Log().Error(err)
return ctx.Response().Json(500, map[string]any{
"error": "Could not load the task.",
})
}
+ if response := facades.Gate().WithContext(ctx).Inspect(policies.DeleteTask, nil); !response.Allowed() {
+ return ctx.Response().Json(403, map[string]any{
+ "error": response.Message(),
+ })
+ }
+
if _, err := facades.Orm().WithContext(ctx).Query().Delete(&task); err != nil {
facades.Log().Error(err)
return ctx.Response().Json(500, map[string]any{
"error": "Could not delete the task.",
})
}
return ctx.Response().NoContent()
}
```
- `Store` asks before it reads the body, so a member gets a `403` even when the body is wrong.
- `Destroy` finds the task first, so a missing task is still a `404`. Then it asks the policy, and deletes only if allowed.
`requests.http` logs in as Ben, who can't create tasks anymore, so add a request that logs in as Ana. Whichever login you run last sets `{{token}}`:
```diff lang="http" title="requests.http" collapse={1-4} collapse={38-105}
### Register
POST http://localhost:3000/api/v1/register
Content-Type: application/json
{
"name": "Ben",
"email": "ben@example.com",
"password": "secret123"
}
-### Log in
+### Log in as Ben
POST http://localhost:3000/api/v1/login
Content-Type: application/json
{
"email": "ben@example.com",
"password": "secret123"
+}
+
+> {%
+ client.global.set("token", response.body.token);
+%}
+
+### Log in as Ana
+POST http://localhost:3000/api/v1/login
+Content-Type: application/json
+
+{
+ "email": "ana@example.com",
+ "password": "password"
}
> {%
client.global.set("token", response.body.token);
%}
### Me
GET http://localhost:3000/api/v1/me
Authorization: Bearer {{token}}
### Refresh
POST http://localhost:3000/api/v1/refresh
Authorization: Bearer {{token}}
> {%
client.global.set("token", response.body.token);
%}
### Log out
POST http://localhost:3000/api/v1/logout
Authorization: Bearer {{token}}
### Create task
POST http://localhost:3000/api/v1/tasks
Authorization: Bearer {{token}}
Content-Type: application/json
{
"title": "Ship v1",
"description": "The first release"
}
### Create task with bad data
POST http://localhost:3000/api/v1/tasks
Authorization: Bearer {{token}}
Content-Type: application/json
{
"title": " ",
"assigned_to": 99,
"due_at": "2020-01-01"
}
### Show task
GET http://localhost:3000/api/v1/tasks/1
Authorization: Bearer {{token}}
### List tasks
GET http://localhost:3000/api/v1/tasks?status=pending&per_page=5
Authorization: Bearer {{token}}
### Update task
PATCH http://localhost:3000/api/v1/tasks/1
Authorization: Bearer {{token}}
Content-Type: application/json
{
"status": "in-progress"
}
### Delete task
DELETE http://localhost:3000/api/v1/tasks/1
Authorization: Bearer {{token}}
### List users
GET http://localhost:3000/api/v1/users
Authorization: Bearer {{token}}
### Show user
GET http://localhost:3000/api/v1/users/2
Authorization: Bearer {{token}}
### Hello
GET http://localhost:3000/hello
```
Ben is refused with "Only managers can create tasks.", and the same for deleting. Ana gets through. Her new task is task 8, assigned to Ben:
```bash t3:createdeny=/"(Only managers can create tasks[.])"/
$ curl -X POST http://localhost:3000/api/v1/tasks \
-H "Authorization: Bearer $BEN" \
-H "Content-Type: application/json" \
-d '{"title":"Ship v3","description":"The third release"}'
{"error":"Only managers can create tasks."}
$ curl -s -o /dev/null -w "%{http_code}\n" \
-X POST http://localhost:3000/api/v1/tasks \
-H "Authorization: Bearer $ANA" \
-H "Content-Type: application/json" \
-d '{"title":"Ship v3","description":"The third release","assigned_to":5}'
201
$ curl -X DELETE http://localhost:3000/api/v1/tasks/1 -H "Authorization: Bearer $BEN"
{"error":"Only managers can delete tasks."}
$ curl -s -o /dev/null -w "%{http_code}\n" \
-X DELETE http://localhost:3000/api/v1/tasks/1 \
-H "Authorization: Bearer $ANA"
204
```
## Members change the status of their own tasks
Ben should be able to change the status of his own task, and nothing else. The policy needs the task and the changed fields to decide, so the handler passes both:
```diff lang="go" title="app/policies/task_policy.go" collapse={10-20} t1:changes=/arguments."(changes)"/ t2:status=/field != "(status)"/ t3:assigned=/([*]task[.]AssignedTo != user[.]ID)/
package policies
import (
"context"
+
+ "taskflow/app/http/authuser"
+ "taskflow/app/models"
"github.com/goravel/framework/auth/access"
contractsaccess "github.com/goravel/framework/contracts/auth/access"
)
type TaskPolicy struct {
}
func NewTaskPolicy() *TaskPolicy {
return &TaskPolicy{}
}
func (r *TaskPolicy) Create(ctx context.Context, arguments map[string]any) contractsaccess.Response {
return access.NewDenyResponse("Only managers can create tasks.")
}
+func (r *TaskPolicy) Update(ctx context.Context, arguments map[string]any) contractsaccess.Response {
+ user := authuser.From(ctx)
+ task := arguments["task"].(*models.Task)
+ changes := arguments["changes"].(map[string]any)
+
+ if user == nil || task.AssignedTo == nil || *task.AssignedTo != user.ID {
+ return access.NewDenyResponse("You can only change tasks assigned to you.")
+ }
+ for field := range changes {
+ if field != "status" {
+ return access.NewDenyResponse("You can only change the status of a task.")
+ }
+ }
+
+ return access.NewAllowResponse()
+}
+
func (r *TaskPolicy) Delete(ctx context.Context, arguments map[string]any) contractsaccess.Response {
return access.NewDenyResponse("Only managers can delete tasks.")
}
```
- Managers never get here, because `Before` already let them through.
- A task assigned to someone else, or to nobody, is denied.
- Any changed field other than `status` is denied.
- The type assertions can't fail, because our handler builds the arguments. If a bug ever passed the wrong type, a member's request would answer `500`.
Name the ability and register it:
```diff lang="go" title="app/policies/abilities.go"
package policies
const (
ViewUsers = "view-users"
CreateTask = "create-task"
+ UpdateTask = "update-task"
DeleteTask = "delete-task"
)
```
```diff lang="go" title="bootstrap/app.go" collapse={1-61}
package bootstrap
import (
"context"
"strings"
"github.com/goravel/framework/auth/access"
contractsaccess "github.com/goravel/framework/contracts/auth/access"
"github.com/goravel/framework/contracts/database/schema"
contractsfoundation "github.com/goravel/framework/contracts/foundation"
"github.com/goravel/framework/contracts/http"
"github.com/goravel/framework/foundation"
"github.com/goravel/framework/http/limit"
"taskflow/app/enums"
"taskflow/app/facades"
"taskflow/app/http/authuser"
"taskflow/app/models"
"taskflow/app/policies"
"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{}},
})
facades.RateLimiter().For("login", func(ctx http.Context) http.Limit {
email := strings.ToLower(strings.TrimSpace(ctx.Request().Input("email")))
return limit.PerMinute(5).By(email).Response(func(ctx http.Context) {
ctx.Response().Json(429, map[string]any{
"error": "Too many login attempts. Try again in a minute.",
}).Abort()
})
})
facades.Gate().Before(func(ctx context.Context, ability string, arguments map[string]any) contractsaccess.Response {
if user := authuser.From(ctx); user != nil && user.Role == enums.RoleManager {
return access.NewAllowResponse()
}
return nil
})
facades.Gate().Define(policies.ViewUsers, func(ctx context.Context, arguments map[string]any) contractsaccess.Response {
return access.NewDenyResponse("Only managers can see the users.")
})
taskPolicy := policies.NewTaskPolicy()
facades.Gate().Define(policies.CreateTask, taskPolicy.Create)
+ facades.Gate().Define(policies.UpdateTask, taskPolicy.Update)
facades.Gate().Define(policies.DeleteTask, taskPolicy.Delete)
}).
Create()
}
```
`Update` passes the task and the changes:
```diff lang="go" title="app/http/controllers/task_controller.go" collapse={1-178} collapse={198-245} t1:changes=/"(changes)":/
package controllers
import (
"errors"
"taskflow/app/enums"
"taskflow/app/facades"
"taskflow/app/http/authuser"
"taskflow/app/http/requests"
"taskflow/app/models"
"taskflow/app/policies"
"github.com/goravel/framework/contracts/http"
frameworkerrors "github.com/goravel/framework/errors"
"github.com/goravel/framework/validation"
)
type TaskController struct {
// Dependent services
}
func NewTaskController() *TaskController {
return &TaskController{
// Inject services
}
}
func (r *TaskController) Store(ctx http.Context) http.Response {
if response := facades.Gate().WithContext(ctx).Inspect(policies.CreateTask, nil); !response.Allowed() {
return ctx.Response().Json(403, map[string]any{
"error": response.Message(),
})
}
var request requests.StoreTaskRequest
validationErrors, err := ctx.Request().ValidateRequest(&request)
if err != nil {
facades.Log().Error(err)
return ctx.Response().Json(500, map[string]any{
"error": "Could not save the task.",
})
}
if validationErrors != nil {
return ctx.Response().Json(422, map[string]any{
"errors": validationErrors.All(),
})
}
task := models.Task{
CreatedBy: authuser.From(ctx).ID,
AssignedTo: request.AssignedTo,
Title: request.Title,
Description: request.Description,
Status: enums.StatusPending,
DueAt: request.DueAt,
}
if err := facades.Orm().WithContext(ctx).Query().Create(&task); err != nil {
facades.Log().Error(err)
return ctx.Response().Json(500, map[string]any{
"error": "Could not save the task.",
})
}
var saved models.Task
err = facades.Orm().WithContext(ctx).Query().With("Creator").With("Assignee").Find(&saved, task.ID)
if err != nil {
facades.Log().Error(err)
return ctx.Response().Json(500, map[string]any{
"error": "Could not load the task.",
})
}
return ctx.Response().Json(201, saved)
}
func (r *TaskController) Show(ctx http.Context) http.Response {
var task models.Task
err := facades.Orm().WithContext(ctx).Query().
With("Creator").
With("Assignee").
FindOrFail(&task, ctx.Request().RouteInt("id"))
if errors.Is(err, frameworkerrors.OrmRecordNotFound) {
return ctx.Response().Json(404, map[string]any{
"error": "The task does not exist.",
})
}
if err != nil {
facades.Log().Error(err)
return ctx.Response().Json(500, map[string]any{
"error": "Could not load the task.",
})
}
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",
"per_page": "bail|filled|integer|min:1|max:100",
})
if err != nil {
facades.Log().Error(err)
return ctx.Response().Json(500, map[string]any{
"error": "Could not list the tasks.",
})
}
if validator.Fails() {
return ctx.Response().Json(422, map[string]any{
"errors": validator.Errors().All(),
})
}
page := ctx.Request().QueryInt("page", 1)
perPage := ctx.Request().QueryInt("per_page", 15)
query := facades.Orm().WithContext(ctx).Query()
if status := ctx.Request().Query("status"); status != "" {
query = query.Where("status", status)
}
var tasks []models.Task
var total int64
err = query.With("Creator").With("Assignee").OrderByDesc("id").Paginate(page, perPage, &tasks, &total)
if err != nil {
facades.Log().Error(err)
return ctx.Response().Json(500, map[string]any{
"error": "Could not list the tasks.",
})
}
return ctx.Response().Json(200, map[string]any{
"data": tasks,
"meta": map[string]any{
"page": page,
"per_page": perPage,
"total": total,
},
})
}
func (r *TaskController) Update(ctx http.Context) http.Response {
var task models.Task
err := facades.Orm().WithContext(ctx).Query().FindOrFail(&task, ctx.Request().RouteInt("id"))
if errors.Is(err, frameworkerrors.OrmRecordNotFound) {
return ctx.Response().Json(404, map[string]any{
"error": "The task does not exist.",
})
}
if err != nil {
facades.Log().Error(err)
return ctx.Response().Json(500, map[string]any{
"error": "Could not load the task.",
})
}
var request requests.UpdateTaskRequest
if err := request.Authorize(ctx); err != nil {
facades.Log().Error(err)
return ctx.Response().Json(500, map[string]any{
"error": "Could not save the task.",
})
}
validator, err := ctx.Request().Validate(
request.Rules(ctx),
validation.Filters(request.Filters(ctx)),
validation.Attributes(request.Attributes(ctx)),
)
if err != nil {
facades.Log().Error(err)
return ctx.Response().Json(500, map[string]any{
"error": "Could not save the task.",
})
}
if validator.Fails() {
return ctx.Response().Json(422, map[string]any{
"errors": validator.Errors().All(),
})
}
- if changes := validator.Validated(); len(changes) > 0 {
+ changes := validator.Validated()
+ response := facades.Gate().WithContext(ctx).Inspect(policies.UpdateTask, map[string]any{
+ "task": &task,
+ "changes": changes,
+ })
+ if !response.Allowed() {
+ return ctx.Response().Json(403, map[string]any{
+ "error": response.Message(),
+ })
+ }
+
+ if len(changes) > 0 {
if _, err := facades.Orm().WithContext(ctx).Query().Model(&task).Update(changes); err != nil {
facades.Log().Error(err)
return ctx.Response().Json(500, map[string]any{
"error": "Could not save the task.",
})
}
}
var saved models.Task
err = facades.Orm().WithContext(ctx).Query().With("Creator").With("Assignee").Find(&saved, task.ID)
if err != nil {
facades.Log().Error(err)
return ctx.Response().Json(500, map[string]any{
"error": "Could not load the task.",
})
}
return ctx.Response().Json(200, saved)
}
func (r *TaskController) Destroy(ctx http.Context) http.Response {
var task models.Task
err := facades.Orm().WithContext(ctx).Query().FindOrFail(&task, ctx.Request().RouteInt("id"))
if errors.Is(err, frameworkerrors.OrmRecordNotFound) {
return ctx.Response().Json(404, map[string]any{
"error": "The task does not exist.",
})
}
if err != nil {
facades.Log().Error(err)
return ctx.Response().Json(500, map[string]any{
"error": "Could not load the task.",
})
}
if response := facades.Gate().WithContext(ctx).Inspect(policies.DeleteTask, nil); !response.Allowed() {
return ctx.Response().Json(403, map[string]any{
"error": response.Message(),
})
}
if _, err := facades.Orm().WithContext(ctx).Query().Delete(&task); err != nil {
facades.Log().Error(err)
return ctx.Response().Json(500, map[string]any{
"error": "Could not delete the task.",
})
}
return ctx.Response().NoContent()
}
```
The policy needs the validated changes, so it runs after the rules:
1. Find the task, or answer `404`.
2. Check the body, or answer `422`.
3. Ask the policy, or answer `403`.
4. Save the changes.
Ben tries his own task 8, then task 2, which is assigned to someone else. Then Ana tries task 2:
```bash t2:status=/(status) of a task/ t3:assigned=/(You can only change tasks assigned to you[.])/
$ curl -s -o /dev/null -w "%{http_code}\n" \
-X PATCH http://localhost:3000/api/v1/tasks/8 \
-H "Authorization: Bearer $BEN" \
-H "Content-Type: application/json" \
-d '{"status":"in-progress"}'
200
$ curl -X PATCH http://localhost:3000/api/v1/tasks/8 \
-H "Authorization: Bearer $BEN" \
-H "Content-Type: application/json" \
-d '{"title":"Ship v3.0"}'
{"error":"You can only change the status of a task."}
$ curl -X PATCH http://localhost:3000/api/v1/tasks/2 \
-H "Authorization: Bearer $BEN" \
-H "Content-Type: application/json" \
-d '{"status":"done"}'
{"error":"You can only change tasks assigned to you."}
$ curl -X PATCH http://localhost:3000/api/v1/tasks/8 \
-H "Authorization: Bearer $BEN" \
-H "Content-Type: application/json" \
-d '{"status":"finished"}'
{"errors":{"status":{"in":"The selected status is invalid."}}}
$ curl -s -o /dev/null -w "%{http_code}\n" \
-X PATCH http://localhost:3000/api/v1/tasks/2 \
-H "Authorization: Bearer $ANA" \
-H "Content-Type: application/json" \
-d '{"title":"Renamed by Ana","status":"done"}'
200
```
- Ben's status change on his own task goes through.
- Renaming it, or touching task 2, gets a `403`.
- A bad status is still a `422`, because the rules run before the policy.
- Ana can change any task.
After, Any and None:
- [`facades.Gate().After`](https://www.goravel.dev/security/authorization#intercepting-gate-checks) runs after every check. Its answer is used only when the check itself returned `nil`.
- [`Any`](https://www.goravel.dev/security/authorization#authorizing-actions) asks whether at least one of several abilities is allowed.
- [`None`](https://www.goravel.dev/security/authorization#authorizing-actions) asks whether none of them is.
Why not Authorize in the form request:
Every form request has an `Authorize` method, and [Validation with Form Requests](https://devchalk.com/goravel/08-validation-form-requests#form-requests) left it returning `nil`. It runs before the handler loads the task, so it can't check the update rule, which needs the task and the changes. The checks go in the handlers instead, where the task is already loaded. `Store`'s check could go in `Authorize`, but keeping every check in the handlers means there is one place to look.
Members can read every task, and change only the status of their own.
---
Previous: [11. Task CRUD](https://devchalk.com/goravel/11-task-crud.md)
Next: [13. Feature Testing](https://devchalk.com/goravel/13-feature-testing.md)
Series: [Goravel](https://devchalk.com/goravel.md)
# Feature Testing
> Tests for logins, roles and tasks.
Goravel series, part 13 of 20. By Krishan Kumar. Published 2026-10-25.
Source: https://devchalk.com/goravel/13-feature-testing
Code: https://github.com/krishankumar01/goravel-taskflow/tree/part-13
So far, every rule in TaskFlow was checked by sending curl requests by hand. After twelve parts there are many rules, and a change in one part can break a rule from another, for example a policy change that lets Ben delete Ana's tasks. This part adds tests that send the same requests and check the answers, so one `go test` run checks the rules again after any change.
Start from the Gates & Policies code:
```bash
git clone https://github.com/krishankumar01/goravel-taskflow
cd goravel-taskflow
git checkout part-12
cp .env.example .env
go run . artisan key:generate
go run . artisan jwt:secret
docker compose up -d
go run . artisan migrate:fresh --seed
```
## A database for the tests
The tests need a database of their own. Each feature test starts from empty tables, by rolling the migrations back and running them again, and on the dev database that would wipe Ana, her tasks and everything else, so the tests get their own database, `taskflow_test`:
```bash t1:testdb=/(taskflow_test)/
$ docker compose exec postgres psql -U goravel -d taskflow -c "CREATE DATABASE taskflow_test"
CREATE DATABASE
```
The tests also need settings that point at that database. Goravel reads [`.env.testing`](https://www.goravel.dev/testing/getting-started#custom-environment-file) instead of `.env` when `go test` gets `--env=.env.testing`. It starts as a copy of `.env.example`:
```bash
$ cp .env.example .env.testing
```
These lines change:
```diff lang="ini" title=".env.testing" collapse={8-15} t1:testdb=/(taskflow_test)/ t2:rounds=/(HASH_ROUNDS)=/ t3:store=/(CACHE_STORE)=/
APP_NAME=Goravel
-APP_ENV=local
-APP_KEY=
+APP_ENV=testing
+APP_KEY=2Nr9bpCIal8Dj0qYE7IviHcYcwzmVqoh
APP_DEBUG=true
LOG_CHANNEL=stack
LOG_LEVEL=debug
SESSION_DRIVER=file
SESSION_LIFETIME=120
APP_URL=http://localhost
APP_HOST=127.0.0.1
APP_PORT=3000
-JWT_SECRET=
+JWT_SECRET=xRQKZ4WHIu8bIHANdQVnskdTsa47cya8
DB_CONNECTION=postgres
DB_HOST=127.0.0.1
DB_PORT=5433
-DB_DATABASE=taskflow
+DB_DATABASE=taskflow_test
DB_USERNAME=goravel
DB_PASSWORD=secret
REDIS_HOST=127.0.0.1
REDIS_PASSWORD=
REDIS_PORT=6380
+
+CACHE_STORE=memory
+HASH_ROUNDS=4
```
The two new settings do nothing yet, since `config/cache.go` always uses Redis and `config/hashing.go` always uses 12 rounds. Both now read their value from the env and keep the old value as the default, so only the tests get the new settings:
```diff lang="go" title="config/cache.go" collapse={1-9} collapse={16-42} t3:store=/"(CACHE_STORE)"/
package config
import (
"github.com/goravel/framework/contracts/cache"
redisfacades "github.com/goravel/redis/facades"
"taskflow/app/facades"
)
func init() {
config := facades.Config()
config.Add("cache", map[string]any{
- "default": "redis",
+ "default": config.Env("CACHE_STORE", "redis"),
// Cache Stores
//
// Here you may define all the cache "stores" for your application as
// well as their drivers. You may even define multiple stores for the
// same cache driver to group types of items stored in your caches.
// Available Drivers: "memory", "custom"
"stores": map[string]any{
"memory": map[string]any{
"driver": "memory",
},
"redis": map[string]any{
"driver": "custom",
"connection": "default",
"via": func() (cache.Driver, error) {
return redisfacades.Cache("redis") // The `redis` value is the key of `stores`
},
},
},
// Cache Key Prefix
//
// When utilizing a RAM based store such as APC or Memcached, there might
// be other applications utilizing the same cache. So, we'll specify a
// value to get prefixed to all our keys, so we can avoid collisions.
// Must: a-zA-Z0-9_-
"prefix": config.GetString("APP_NAME", "goravel") + "_cache",
})
}
```
```diff lang="go" title="config/hashing.go" collapse={1-23} collapse={30-41} t2:rounds=/"(HASH_ROUNDS)"/
package config
import (
"taskflow/app/facades"
)
func init() {
config := facades.Config()
config.Add("hashing", map[string]any{
// Hashing Driver
//
// This option controls the default diver that gets used
// by the framework hash facade.
// Default driver is "bcrypt".
//
// Supported Drivers: "bcrypt", "argon2id"
"driver": "bcrypt",
// Bcrypt Hashing Options
// rounds: The cost factor that should be used to compute the bcrypt hash.
// The cost factor controls how much time is needed to compute a single bcrypt hash.
// The higher the cost factor, the more hashing rounds are done. Increasing the cost
// factor by 1 doubles the necessary time. After a certain point, the returns on
// hashing time versus attacker time are diminishing, so choose your cost factor wisely.
"bcrypt": map[string]any{
- "rounds": 12,
+ "rounds": config.Env("HASH_ROUNDS", 12),
},
// Argon2id Hashing Options
// memory: A memory cost, which defines the memory usage, given in kibibytes.
// time: A time cost, which defines the amount of computation
// realized and therefore the execution time, given in number of iterations.
// threads: A parallelism degree, which defines the number of parallel threads.
"argon2id": map[string]any{
"memory": 65536,
"time": 4,
"threads": 1,
},
})
}
```
- `CACHE_STORE=memory` keeps the tests' cache in memory. The tests empty the cache before each test, and with Redis that would empty the whole Redis server, including the dev app's logged-out tokens and login counts.
- `HASH_ROUNDS=4` makes bcrypt cheap. Every test user and every login hashes a password, and at the usual 12 rounds the suite takes about 6 seconds instead of 1.
`.env.testing` goes into git, so a fresh clone can run the tests once the database exists. Its `APP_KEY` and `JWT_SECRET` are made up, and any 32 characters work, so nothing in it is a real secret. `.gitignore` skips every `.env.*` file except `.env.example`, so add an exception:
```diff lang="ini" title=".gitignore"
.idea
.vscode
.DS_Store
.env
.env.*
!.env.example
+!.env.testing
storage
tmp
```
## A test case that boots the app
A test needs the app started the way `main.go` starts it, with its config, database and routes. Goravel's Testing package does that. Lite only installs the facades picked when the project is created, and [Installation & Project Architecture](https://devchalk.com/goravel/01-installation-project-architecture#create-the-project) didn't pick Testing, so install it now with [`package:install`](https://www.goravel.dev/getting-started/installation):
```bash
$ go run . artisan package:install Testing
INFO Testing depends on Log, Cache, Schema, Orm, Hash, Http, RateLimiter, Session, Storage, Validation, View, Route, they will be installed simultaneously
SUCCESS Facade Testing installed successfully
```
The packages Testing depends on are already installed, so it only adds these:
- `tests/test_case.go`, with the `TestCase` struct every test embeds.
- `tests/feature/example_test.go`, a placeholder test to delete.
- `&testing.ServiceProvider{}` in `bootstrap/providers.go`. It sets up what `RefreshDatabase` and `Http` need.
- `app/facades/testing.go`, the Testing facade, and [testify](https://github.com/stretchr/testify) in `go.mod`.
The new test case boots the app in `init`. The feature tests wipe every table, so a check there stops the run unless the database is a test database:
```diff lang="go" title="tests/test_case.go" t1:boot=/bootstrap[.](Boot)[(][)]/ t2:guard=/(they only run on a database whose name ends in _test)/
package tests
import (
+ "strings"
+
"github.com/goravel/framework/testing"
+ "taskflow/app/facades"
"taskflow/bootstrap"
)
func init() {
bootstrap.Boot()
+
+ if !strings.HasSuffix(facades.Config().GetString("database.connections.postgres.database"), "_test") {
+ panic("the tests drop every table, so they only run on a database whose name ends in _test: go test ./tests/... --env=.env.testing")
+ }
}
type TestCase struct {
testing.TestCase
}
```
- Go runs [`init`](https://go.dev/ref/spec#Package_initialization) once in each test package that imports `taskflow/tests`, before its tests. `bootstrap.Boot()` is the call `main.go` makes, so the tests run the real app.
- The check reads the database name from the config. If the name doesn't end in `_test`, the run stops with the guard's message. That catches a plain `go test` without `--env`, and an `.env.testing` that still says `taskflow`.
- Embedding Goravel's `testing.TestCase` gives every test `Http` for requests and [`RefreshDatabase`](https://www.goravel.dev/testing/getting-started#refresh-database-or-cache) for the empty tables.
## Unit tests for the custom rules
A unit test checks one piece of code on its own, without the database or a request. The custom rules fit that well, because a rule's `Passes` gets a value and returns true or false. They are `MaxBytes` from [Register & Login with JWT](https://devchalk.com/goravel/09-register-login-jwt#registering) and `FutureDate` from [Validation with Form Requests](https://devchalk.com/goravel/08-validation-form-requests#a-custom-rule). [`make:test`](https://www.goravel.dev/testing/getting-started#creating-tests) creates their test file:
```bash
$ go run . artisan make:test unit/RulesTest
SUCCESS Test created successfully
```
It writes a [testify suite](https://pkg.go.dev/github.com/stretchr/testify/suite), a struct whose methods are the tests:
```go title="tests/unit/rules_test.go"
package unit
import (
"testing"
"github.com/stretchr/testify/suite"
"taskflow/tests"
)
type RulesTestSuite struct {
suite.Suite
tests.TestCase
}
func TestRulesTestSuite(t *testing.T) {
suite.Run(t, new(RulesTestSuite))
}
// SetupTest will run before each test in the suite.
func (s *RulesTestSuite) SetupTest() {
}
// TearDownTest will run after each test in the suite.
func (s *RulesTestSuite) TearDownTest() {
}
func (s *RulesTestSuite) TestIndex() {
// TODO
}
```
- `RulesTestSuite` embeds `suite.Suite`, which gives it testify's checks like `s.Equal`, and `tests.TestCase` from the last section.
- `TestRulesTestSuite` is the only function `go test` itself runs. Its `suite.Run` then calls every method on the struct whose name starts with `Test`.
- `SetupTest` and `TearDownTest` run before and after each of those methods, and `TestIndex` is a placeholder.
The rules need no setup, so the file becomes one test per rule:
```go title="tests/unit/rules_test.go" t1:emoji=/("19 emoji are 76 bytes")/ t3:casename=/c[.](name)[)]/
package unit
import (
"context"
"strings"
"testing"
"github.com/goravel/framework/support/carbon"
"github.com/stretchr/testify/suite"
"taskflow/app/rules"
"taskflow/tests"
)
type RulesTestSuite struct {
suite.Suite
tests.TestCase
}
func TestRulesTestSuite(t *testing.T) {
suite.Run(t, new(RulesTestSuite))
}
func (s *RulesTestSuite) TestMaxBytes() {
cases := []struct {
name string
value any
passes bool
}{
{"72 bytes", strings.Repeat("a", 72), true},
{"73 bytes", strings.Repeat("a", 73), false},
{"18 emoji are 72 bytes", strings.Repeat("🔒", 18), true},
{"19 emoji are 76 bytes", strings.Repeat("🔒", 19), false},
{"not a string", 12345, false},
}
rule := &rules.MaxBytes{}
for _, c := range cases {
s.Equal(c.passes, rule.Passes(context.Background(), nil, c.value, "72"), c.name)
}
}
func (s *RulesTestSuite) TestFutureDate() {
cases := []struct {
name string
value any
passes bool
}{
{"tomorrow", carbon.Now().AddDay().ToDateTimeString(), true},
{"yesterday", carbon.Now().SubDay().ToDateTimeString(), false},
{"not a date", "soon", false},
{"not a string", 12345, false},
}
rule := &rules.FutureDate{}
for _, c := range cases {
s.Equal(c.passes, rule.Passes(context.Background(), nil, c.value), c.name)
}
}
```
- Each case has a name, a value, and whether the rule should pass it. This [table-driven](https://go.dev/wiki/TableDrivenTests) style is common in Go tests, and when a case fails, `s.Equal` prints the case's name.
- 🔒 is 4 bytes, so 18 of them are exactly 72 bytes and pass. 19 of them are only 19 characters but 76 bytes, and that case fails because the rule counts bytes, not characters.
The tests run with `go test ./tests/... --env=.env.testing`. Without `--env`, the guard stops the run before any test, the unit tests included, since they import `taskflow/tests` too, and its `init` boots the app. Here and below, the output shows only the test lines:
```bash t2:guard=/(they only run on a database whose name ends in _test)/
$ go test ./tests/...
? taskflow/tests [no test files]
panic: the tests drop every table, so they only run on a database whose name ends in _test: go test ./tests/... --env=.env.testing
FAIL taskflow/tests/unit 0.714s
FAIL
```
With it, both tests pass. [`-v`](https://pkg.go.dev/cmd/go#hdr-Testing_flags) lists each one:
```bash
$ go test ./tests/... --env=.env.testing -v
? taskflow/tests [no test files]
=== RUN TestRulesTestSuite
=== RUN TestRulesTestSuite/TestFutureDate
=== RUN TestRulesTestSuite/TestMaxBytes
--- PASS: TestRulesTestSuite (0.00s)
--- PASS: TestRulesTestSuite/TestFutureDate (0.00s)
--- PASS: TestRulesTestSuite/TestMaxBytes (0.00s)
PASS
ok taskflow/tests/unit 0.398s
```
> **Note** Go also lets a unit test sit next to its code, like `app/rules/max_bytes_test.go`, and a plain `go test ./app/rules/` runs it. Either way works, and TaskFlow keeps every test in `tests/` so one command runs them all.
## Requests without a token
A feature test sends a request through the routes, the middleware and the handler, as curl did, and checks the answer. The first one checks the simplest rule, that every route behind the auth middleware refuses a request without a valid token. `make:test` creates its file:
```bash
$ go run . artisan make:test feature/AuthTest
SUCCESS Test created successfully
```
It writes the same suite as `RulesTest`. Replace it with the checks:
```go title="tests/feature/auth_test.go" t3:http=/s[.](Http)[(]s[.]T[(][)][)]/ t2:unauth=/[.](AssertUnauthorized)[(]/
package feature
import (
"testing"
"github.com/goravel/framework/support/http"
"github.com/stretchr/testify/suite"
"taskflow/app/facades"
"taskflow/app/models"
"taskflow/tests"
)
type AuthTestSuite struct {
suite.Suite
tests.TestCase
}
func TestAuthTestSuite(t *testing.T) {
suite.Run(t, new(AuthTestSuite))
}
func (s *AuthTestSuite) SetupTest() {
s.RefreshDatabase()
}
func (s *AuthTestSuite) TestRoutesNeedAToken() {
for _, path := range []string{"/api/v1/tasks", "/api/v1/tasks/1", "/api/v1/users", "/api/v1/me"} {
response, err := s.Http(s.T()).Get(path)
s.Require().NoError(err)
response.AssertUnauthorized()
}
}
func (s *AuthTestSuite) TestABadTokenIsRefused() {
response, err := s.Http(s.T()).WithToken("not-a-token").Get("/api/v1/tasks")
s.Require().NoError(err)
response.AssertUnauthorized()
}
func (s *AuthTestSuite) TestAWrongPasswordIsRefused() {
var ana models.User
s.Require().NoError(facades.Orm().Factory().Create(&ana, map[string]any{"Email": "ana@example.com"}))
body, err := http.NewBody().SetFields(map[string]any{"email": "ana@example.com", "password": "wrong"}).Build()
s.Require().NoError(err)
response, err := s.Http(s.T()).WithHeader("Content-Type", body.ContentType()).Post("/api/v1/login", body.Reader())
s.Require().NoError(err)
response.AssertUnauthorized()
}
```
- `SetupTest` calls `RefreshDatabase`, so every test starts with empty tables in `taskflow_test`.
- `s.Http(s.T())` is Goravel's [test client](https://www.goravel.dev/testing/http-tests#make-requests). It hands the request straight to the router, with no server and no port, so `air` doesn't need to run.
- The login request's body is built with Goravel's [`http.NewBody()`](https://www.goravel.dev/testing/http-tests#building-body): `SetFields` takes the fields as a map, and `Build` turns them into JSON, so no JSON is written by hand. `body.Reader()` is what `Post` sends, and `body.ContentType()` is the header that goes with it.
- `AssertUnauthorized` checks for a `401`.
The run now includes the feature tests:
```bash
$ go test ./tests/... --env=.env.testing
? taskflow/tests [no test files]
ok taskflow/tests/feature 0.769s
ok taskflow/tests/unit 0.653s
```
The test client and its assertions:
`s.Http(s.T())` starts a request. These are the methods the tests here use, and Goravel's [HTTP tests](https://www.goravel.dev/testing/http-tests) docs list the rest:
- `WithToken` sends a `Bearer` token, and `WithHeader` any other header.
- `Get` takes the path, and `Post`, `Patch` and `Delete` also take a body, an `io.Reader`, or `nil` for none.
- `Bind` fills a struct from the response's JSON.
- `AssertStatus` checks for any code, and the common ones have their own name, like `AssertOk`, `AssertCreated`, `AssertUnauthorized` and `AssertNotFound`.
- [`AssertFluentJson`](https://www.goravel.dev/testing/http-tests#fluent-json-testing) checks top-level keys with `Where`, so a nested value like `errors.title` needs `Bind`.
Goravel's assertions, like testify's [`assert`](https://pkg.go.dev/github.com/stretchr/testify/assert), mark a test as failed and let it carry on. testify's [`require`](https://pkg.go.dev/github.com/stretchr/testify/require), called as `s.Require()`, stops the test at the first failure instead. The tests use it to check each request's error, since a failed request has no response to check.
## Every role rule in one table
The next test checks every role rule from [Gates & Policies](https://devchalk.com/goravel/12-gates-policies#the-rules). Each rule is one row in a table, saying who sends which request and the status it should get, so a broken rule fails under its own name. `make:test` creates the file:
```bash
$ go run . artisan make:test feature/TaskTest
SUCCESS Test created successfully
```
Replace the new suite with the table and the users it needs:
```go title="tests/feature/task_test.go" t3:row=/"(a member can't delete a task)"/ t2:flush=/(Cache[(][)][.]Flush)[(]/ t1:login=/[)] (login)[(]email/
package feature
import (
"fmt"
"testing"
contractshttp "github.com/goravel/framework/contracts/testing/http"
"github.com/goravel/framework/support/http"
"github.com/stretchr/testify/suite"
"taskflow/app/enums"
"taskflow/app/facades"
"taskflow/app/models"
"taskflow/tests"
)
type TaskTestSuite struct {
suite.Suite
tests.TestCase
ana models.User
ben models.User
benTask models.Task
catTask models.Task
}
func TestTaskTestSuite(t *testing.T) {
suite.Run(t, new(TaskTestSuite))
}
func (s *TaskTestSuite) SetupTest() {
s.RefreshDatabase()
s.Require().True(facades.Cache().Flush())
var ana, ben, cat models.User
s.Require().NoError(facades.Orm().Factory().Create(&ana, map[string]any{"Email": "ana@example.com", "Role": enums.RoleManager}))
s.Require().NoError(facades.Orm().Factory().Create(&ben, map[string]any{"Email": "ben@example.com"}))
s.Require().NoError(facades.Orm().Factory().Create(&cat, map[string]any{"Email": "cat@example.com"}))
var benTask, catTask models.Task
s.Require().NoError(facades.Orm().Factory().Create(&benTask, map[string]any{"CreatedBy": ana.ID, "AssignedTo": &ben.ID}))
s.Require().NoError(facades.Orm().Factory().Create(&catTask, map[string]any{"CreatedBy": ana.ID, "AssignedTo": &cat.ID}))
s.ana, s.ben, s.benTask, s.catTask = ana, ben, benTask, catTask
}
func (s *TaskTestSuite) login(email string) string {
body, err := http.NewBody().SetFields(map[string]any{"email": email, "password": "password"}).Build()
s.Require().NoError(err)
response, err := s.Http(s.T()).WithHeader("Content-Type", body.ContentType()).Post("/api/v1/login", body.Reader())
s.Require().NoError(err)
response.AssertOk()
var login struct {
Token string `json:"token"`
}
s.Require().NoError(response.Bind(&login))
s.Require().NotEmpty(login.Token)
return login.Token
}
func (s *TaskTestSuite) TestRoles() {
ana := s.login("ana@example.com")
ben := s.login("ben@example.com")
benTask := fmt.Sprintf("/api/v1/tasks/%d", s.benTask.ID)
catTask := fmt.Sprintf("/api/v1/tasks/%d", s.catTask.ID)
newTask := map[string]any{"title": "Write the tests", "description": "Cover every role rule."}
cases := []struct {
name string
token string
method string
path string
body map[string]any
status int
}{
{"a member reads tasks", ben, "GET", "/api/v1/tasks", nil, 200},
{"a member can't list users", ben, "GET", "/api/v1/users", nil, 403},
{"a manager lists users", ana, "GET", "/api/v1/users", nil, 200},
{"a member can't create a task", ben, "POST", "/api/v1/tasks", newTask, 403},
{"a manager creates a task", ana, "POST", "/api/v1/tasks", newTask, 201},
{"a member changes the status of his task", ben, "PATCH", benTask, map[string]any{"status": "done"}, 200},
{"a member can't rename his task", ben, "PATCH", benTask, map[string]any{"title": "Renamed"}, 403},
{"a member can't change someone else's task", ben, "PATCH", catTask, map[string]any{"status": "done"}, 403},
{"a member reads a task", ben, "GET", benTask, nil, 200},
{"a manager reads a task", ana, "GET", benTask, nil, 200},
{"a member can't see a user", ben, "GET", fmt.Sprintf("/api/v1/users/%d", s.ana.ID), nil, 403},
{"a manager sees a user", ana, "GET", fmt.Sprintf("/api/v1/users/%d", s.ben.ID), nil, 200},
{"a member can't reassign his task", ben, "PATCH", benTask, map[string]any{"assigned_to": s.ana.ID}, 403},
{"a manager changes any task", ana, "PATCH", catTask, map[string]any{"title": "Renamed"}, 200},
{"a member can't delete a task", ben, "DELETE", catTask, nil, 403},
{"a manager deletes a task", ana, "DELETE", catTask, nil, 204},
}
for _, c := range cases {
s.Run(c.name, func() {
body, err := http.NewBody().SetFields(c.body).Build()
s.Require().NoError(err)
request := s.Http(s.T()).WithToken(c.token).WithHeader("Content-Type", body.ContentType())
var response contractshttp.Response
switch c.method {
case "GET":
response, err = request.Get(c.path)
case "POST":
response, err = request.Post(c.path, body.Reader())
case "PATCH":
response, err = request.Patch(c.path, body.Reader())
case "DELETE":
response, err = request.Delete(c.path, nil)
}
s.Require().NoError(err)
response.AssertStatus(c.status)
})
}
}
```
- `SetupTest` creates Ana, Ben and Cat with the user factory from [Factories & Seeding](https://devchalk.com/goravel/07-factories-seeding#the-user-factory), so each has the password `password`. Ben and Cat each get a task.
- `login` logs in through `POST /api/v1/login`, the way a client does, and reads the token with `Bind`.
- Each row's body is a map, or `nil` when the request sends none. Every row builds a body, so the loop stays the same for all of them, but only `POST` and `PATCH` send it.
- The login limit from [Auth Middleware & Rate Limits](https://devchalk.com/goravel/10-auth-middleware#limiting-login-attempts) counts attempts in the cache, and `RefreshDatabase` doesn't touch the cache. `Cache().Flush()` resets the counts, so one test's logins don't use up the next test's.
- [`s.Run`](https://pkg.go.dev/github.com/stretchr/testify/suite#Suite.Run) runs each row as its own test, named after the row.
- The rows share the same tasks, so Ana's delete comes last.
`AuthTest` logs in too, so it gets the same `Flush`, and each suite starts with clean counts whichever runs first:
```diff lang="go" title="tests/feature/auth_test.go" collapse={1-22} collapse={28-51} t2:flush=/(Cache[(][)][.]Flush)[(]/
package feature
import (
"testing"
"github.com/goravel/framework/support/http"
"github.com/stretchr/testify/suite"
"taskflow/app/facades"
"taskflow/app/models"
"taskflow/tests"
)
type AuthTestSuite struct {
suite.Suite
tests.TestCase
}
func TestAuthTestSuite(t *testing.T) {
suite.Run(t, new(AuthTestSuite))
}
func (s *AuthTestSuite) SetupTest() {
s.RefreshDatabase()
+ s.Require().True(facades.Cache().Flush())
}
func (s *AuthTestSuite) TestRoutesNeedAToken() {
for _, path := range []string{"/api/v1/tasks", "/api/v1/tasks/1", "/api/v1/users", "/api/v1/me"} {
response, err := s.Http(s.T()).Get(path)
s.Require().NoError(err)
response.AssertUnauthorized()
}
}
func (s *AuthTestSuite) TestABadTokenIsRefused() {
response, err := s.Http(s.T()).WithToken("not-a-token").Get("/api/v1/tasks")
s.Require().NoError(err)
response.AssertUnauthorized()
}
func (s *AuthTestSuite) TestAWrongPasswordIsRefused() {
var ana models.User
s.Require().NoError(facades.Orm().Factory().Create(&ana, map[string]any{"Email": "ana@example.com"}))
body, err := http.NewBody().SetFields(map[string]any{"email": "ana@example.com", "password": "wrong"}).Build()
s.Require().NoError(err)
response, err := s.Http(s.T()).WithHeader("Content-Type", body.ContentType()).Post("/api/v1/login", body.Reader())
s.Require().NoError(err)
response.AssertUnauthorized()
}
```
With `-v`, each row shows as its own test:
```bash
$ go test ./tests/... --env=.env.testing -run TestTaskTestSuite -v
? taskflow/tests [no test files]
--- PASS: TestTaskTestSuite (0.09s)
--- PASS: TestTaskTestSuite/TestRoles (0.09s)
--- PASS: TestTaskTestSuite/TestRoles/a_member_reads_tasks (0.00s)
--- PASS: TestTaskTestSuite/TestRoles/a_member_can't_list_users (0.00s)
--- PASS: TestTaskTestSuite/TestRoles/a_manager_lists_users (0.00s)
--- PASS: TestTaskTestSuite/TestRoles/a_member_can't_create_a_task (0.00s)
--- PASS: TestTaskTestSuite/TestRoles/a_manager_creates_a_task (0.00s)
--- PASS: TestTaskTestSuite/TestRoles/a_member_changes_the_status_of_his_task (0.00s)
--- PASS: TestTaskTestSuite/TestRoles/a_member_can't_rename_his_task (0.00s)
--- PASS: TestTaskTestSuite/TestRoles/a_member_can't_change_someone_else's_task (0.00s)
--- PASS: TestTaskTestSuite/TestRoles/a_member_reads_a_task (0.00s)
--- PASS: TestTaskTestSuite/TestRoles/a_manager_reads_a_task (0.00s)
--- PASS: TestTaskTestSuite/TestRoles/a_member_can't_see_a_user (0.00s)
--- PASS: TestTaskTestSuite/TestRoles/a_manager_sees_a_user (0.00s)
--- PASS: TestTaskTestSuite/TestRoles/a_member_can't_reassign_his_task (0.00s)
--- PASS: TestTaskTestSuite/TestRoles/a_manager_changes_any_task (0.00s)
--- PASS: TestTaskTestSuite/TestRoles/a_member_can't_delete_a_task (0.00s)
--- PASS: TestTaskTestSuite/TestRoles/a_manager_deletes_a_task (0.00s)
ok taskflow/tests/feature 0.801s
ok taskflow/tests/unit 0.709s [no tests to run]
```
To see the table catch a mistake, in `app/policies/task_policy.go`, change `Delete` to return `access.NewAllowResponse()`, so members may delete, and run the tests again:
```bash t3:row=/(a_member_can't_delete_a_task)/
$ go test ./tests/... --env=.env.testing
? taskflow/tests [no test files]
--- FAIL: TestTaskTestSuite (0.05s)
--- FAIL: TestTaskTestSuite/TestRoles (0.05s)
--- FAIL: TestTaskTestSuite/TestRoles/a_member_can't_delete_a_task (0.00s)
Expected response status code [403] but received 204.
--- FAIL: TestTaskTestSuite/TestRoles/a_manager_deletes_a_task (0.00s)
Expected response status code [204] but received 404.
FAIL
FAIL taskflow/tests/feature 0.845s
ok taskflow/tests/unit 0.677s
FAIL
```
- The member's row names the rule and the status it got.
- Ana's delete then gets a `404`, because Ben's went through and the task is gone.
Change `Delete` back before going on.
## Checking the body and the database
A status code doesn't show everything a request did. A `201` could carry the wrong creator, and a `204` could come back without deleting anything, so most of the new tests check the body or the database, and two more cover a missing task and logging out:
```diff lang="go" title="tests/feature/task_test.go" collapse={1-112} t1:bind=/response[.](Bind)[(]&task[)]/ t3:count=/[.](Count)[(][)]/
package feature
import (
"fmt"
"testing"
contractshttp "github.com/goravel/framework/contracts/testing/http"
"github.com/goravel/framework/support/http"
"github.com/stretchr/testify/suite"
"taskflow/app/enums"
"taskflow/app/facades"
"taskflow/app/models"
"taskflow/tests"
)
type TaskTestSuite struct {
suite.Suite
tests.TestCase
ana models.User
ben models.User
benTask models.Task
catTask models.Task
}
func TestTaskTestSuite(t *testing.T) {
suite.Run(t, new(TaskTestSuite))
}
func (s *TaskTestSuite) SetupTest() {
s.RefreshDatabase()
s.Require().True(facades.Cache().Flush())
var ana, ben, cat models.User
s.Require().NoError(facades.Orm().Factory().Create(&ana, map[string]any{"Email": "ana@example.com", "Role": enums.RoleManager}))
s.Require().NoError(facades.Orm().Factory().Create(&ben, map[string]any{"Email": "ben@example.com"}))
s.Require().NoError(facades.Orm().Factory().Create(&cat, map[string]any{"Email": "cat@example.com"}))
var benTask, catTask models.Task
s.Require().NoError(facades.Orm().Factory().Create(&benTask, map[string]any{"CreatedBy": ana.ID, "AssignedTo": &ben.ID}))
s.Require().NoError(facades.Orm().Factory().Create(&catTask, map[string]any{"CreatedBy": ana.ID, "AssignedTo": &cat.ID}))
s.ana, s.ben, s.benTask, s.catTask = ana, ben, benTask, catTask
}
func (s *TaskTestSuite) login(email string) string {
body, err := http.NewBody().SetFields(map[string]any{"email": email, "password": "password"}).Build()
s.Require().NoError(err)
response, err := s.Http(s.T()).WithHeader("Content-Type", body.ContentType()).Post("/api/v1/login", body.Reader())
s.Require().NoError(err)
response.AssertOk()
var login struct {
Token string `json:"token"`
}
s.Require().NoError(response.Bind(&login))
s.Require().NotEmpty(login.Token)
return login.Token
}
func (s *TaskTestSuite) TestRoles() {
ana := s.login("ana@example.com")
ben := s.login("ben@example.com")
benTask := fmt.Sprintf("/api/v1/tasks/%d", s.benTask.ID)
catTask := fmt.Sprintf("/api/v1/tasks/%d", s.catTask.ID)
newTask := map[string]any{"title": "Write the tests", "description": "Cover every role rule."}
cases := []struct {
name string
token string
method string
path string
body map[string]any
status int
}{
{"a member reads tasks", ben, "GET", "/api/v1/tasks", nil, 200},
{"a member can't list users", ben, "GET", "/api/v1/users", nil, 403},
{"a manager lists users", ana, "GET", "/api/v1/users", nil, 200},
{"a member can't create a task", ben, "POST", "/api/v1/tasks", newTask, 403},
{"a manager creates a task", ana, "POST", "/api/v1/tasks", newTask, 201},
{"a member changes the status of his task", ben, "PATCH", benTask, map[string]any{"status": "done"}, 200},
{"a member can't rename his task", ben, "PATCH", benTask, map[string]any{"title": "Renamed"}, 403},
{"a member can't change someone else's task", ben, "PATCH", catTask, map[string]any{"status": "done"}, 403},
{"a member reads a task", ben, "GET", benTask, nil, 200},
{"a manager reads a task", ana, "GET", benTask, nil, 200},
{"a member can't see a user", ben, "GET", fmt.Sprintf("/api/v1/users/%d", s.ana.ID), nil, 403},
{"a manager sees a user", ana, "GET", fmt.Sprintf("/api/v1/users/%d", s.ben.ID), nil, 200},
{"a member can't reassign his task", ben, "PATCH", benTask, map[string]any{"assigned_to": s.ana.ID}, 403},
{"a manager changes any task", ana, "PATCH", catTask, map[string]any{"title": "Renamed"}, 200},
{"a member can't delete a task", ben, "DELETE", catTask, nil, 403},
{"a manager deletes a task", ana, "DELETE", catTask, nil, 204},
}
for _, c := range cases {
s.Run(c.name, func() {
body, err := http.NewBody().SetFields(c.body).Build()
s.Require().NoError(err)
request := s.Http(s.T()).WithToken(c.token).WithHeader("Content-Type", body.ContentType())
var response contractshttp.Response
switch c.method {
case "GET":
response, err = request.Get(c.path)
case "POST":
response, err = request.Post(c.path, body.Reader())
case "PATCH":
response, err = request.Patch(c.path, body.Reader())
case "DELETE":
response, err = request.Delete(c.path, nil)
}
s.Require().NoError(err)
response.AssertStatus(c.status)
})
}
}
+
+func (s *TaskTestSuite) TestCreateSetsTheCreator() {
+ body, err := http.NewBody().SetFields(map[string]any{
+ "title": "Write the tests",
+ "description": "Cover every role rule.",
+ "assigned_to": s.ben.ID,
+ }).Build()
+ s.Require().NoError(err)
+ response, err := s.Http(s.T()).WithToken(s.login("ana@example.com")).WithHeader("Content-Type", body.ContentType()).Post("/api/v1/tasks", body.Reader())
+ s.Require().NoError(err)
+
+ response.AssertCreated()
+
+ var task models.Task
+ s.Require().NoError(response.Bind(&task))
+ s.Equal("Write the tests", task.Title)
+ s.Equal(s.ana.ID, task.CreatedBy)
+ s.Equal(s.ben.ID, *task.AssignedTo)
+ s.Equal(enums.StatusPending, task.Status)
+}
+
+func (s *TaskTestSuite) TestAMissingTaskIsNotFound() {
+ response, err := s.Http(s.T()).WithToken(s.login("ana@example.com")).Get("/api/v1/tasks/999")
+ s.Require().NoError(err)
+ response.AssertNotFound()
+}
+
+func (s *TaskTestSuite) TestBadInputIsRefused() {
+ body, err := http.NewBody().SetFields(map[string]any{"description": "No title.", "due_at": "yesterday"}).Build()
+ s.Require().NoError(err)
+ response, err := s.Http(s.T()).WithToken(s.login("ana@example.com")).WithHeader("Content-Type", body.ContentType()).Post("/api/v1/tasks", body.Reader())
+ s.Require().NoError(err)
+
+ response.AssertUnprocessableEntity()
+
+ var refused struct {
+ Errors map[string]any `json:"errors"`
+ }
+ s.Require().NoError(response.Bind(&refused))
+ s.Contains(refused.Errors, "title")
+ s.Contains(refused.Errors, "due_at")
+}
+
+func (s *TaskTestSuite) TestUpdateChangesOnlyTheSentFields() {
+ path := fmt.Sprintf("/api/v1/tasks/%d", s.benTask.ID)
+ body, err := http.NewBody().SetFields(map[string]any{"status": "done"}).Build()
+ s.Require().NoError(err)
+ response, err := s.Http(s.T()).WithToken(s.login("ana@example.com")).WithHeader("Content-Type", body.ContentType()).Patch(path, body.Reader())
+ s.Require().NoError(err)
+
+ response.AssertOk().AssertFluentJson(func(json contractshttp.AssertableJSON) {
+ json.Where("status", "done").
+ Where("title", s.benTask.Title).
+ Where("description", s.benTask.Description)
+ })
+}
+
+func (s *TaskTestSuite) TestDeleteRemovesTheTask() {
+ path := fmt.Sprintf("/api/v1/tasks/%d", s.catTask.ID)
+ response, err := s.Http(s.T()).WithToken(s.login("ana@example.com")).Delete(path, nil)
+ s.Require().NoError(err)
+ response.AssertNoContent()
+
+ count, err := facades.Orm().Query().Model(&models.Task{}).Where("id", s.catTask.ID).Count()
+ s.Require().NoError(err)
+ s.Equal(int64(0), count)
+}
+
+func (s *TaskTestSuite) TestLogoutEndsTheToken() {
+ token := s.login("cat@example.com")
+
+ response, err := s.Http(s.T()).WithToken(token).Post("/api/v1/logout", nil)
+ s.Require().NoError(err)
+ response.AssertNoContent()
+
+ response, err = s.Http(s.T()).WithToken(token).Get("/api/v1/me")
+ s.Require().NoError(err)
+ response.AssertUnauthorized()
+}
```
- `Bind` reads the created task into a `models.Task`, and the test checks that its creator is Ana, its assignee is Ben and its status is `pending`.
- A task id that doesn't exist gets a `404`.
- The bad-input test checks which fields have errors, not the messages, so a reworded message doesn't break it.
- The update test uses [`AssertFluentJson`](https://www.goravel.dev/testing/http-tests#fluent-json-testing) to check that the title and description are still the same after a status-only change.
- After the `204`, the delete test counts the task's rows in the test database, and `Count` must be 0.
- In the logout test, Cat logs out and then sends her token again, and that request gets a `401`.
The whole suite passes:
```bash
$ go test ./tests/... --env=.env.testing
? taskflow/tests [no test files]
ok taskflow/tests/feature 1.071s
ok taskflow/tests/unit 0.713s
```
> **Gotcha** testify keeps one suite struct for all its tests, so after the first test `s.ana` still holds an id, and that's why `SetupTest` creates the users into new variables first. Creating Ana straight into `s.ana` would save her with that old id, but the emptied tables start their ids from 1 again, so the next new user gets the same id:
>
> ```bash
> $ go test ./tests/... --env=.env.testing
> ? taskflow/tests [no test files]
> --- FAIL: TestTaskTestSuite (0.21s)
> --- FAIL: TestTaskTestSuite/TestBadInputIsRefused (0.03s)
> ERROR: duplicate key value violates unique constraint "users_pkey" (SQLSTATE 23505)
> ```
>
> New variables start with no id, so the database gives each one a fresh id.
Why the tests use the real database:
Goravel can swap `facades.Orm()` for a mock, but then every query has to be scripted by hand. These tests run the real app against `taskflow_test` instead. That's slower, but the tests get the same answers a client would.
When a test must not touch a service, such as an email provider, Goravel can [replace a facade with a mock](https://www.goravel.dev/testing/mock). Code that takes its dependencies as arguments instead of calling facades can be given a fake directly.
One `go test ./tests/... --env=.env.testing` now checks every role rule from [Gates & Policies](https://devchalk.com/goravel/12-gates-policies), the token checks and the main task endpoints, in about a second.
---
Previous: [12. Gates & Policies](https://devchalk.com/goravel/12-gates-policies.md)
Next: [14. File Storage](https://devchalk.com/goravel/14-file-storage-attachments.md)
Series: [Goravel](https://devchalk.com/goravel.md)
# File Storage
> Files attached to tasks.
Goravel series, part 14 of 20. By Krishan Kumar. Published 2026-10-26.
Source: https://devchalk.com/goravel/14-file-storage-attachments
Code: https://github.com/krishankumar01/goravel-taskflow/tree/part-14
So far, a task in TaskFlow is only text. A bug report often needs more, like a screenshot of the error or a PDF of the spec. In this part, tasks get attachments: Ben uploads a screenshot to his task, downloads it and deletes it. Each file is saved on a disk, and Postgres keeps a row saying which task it belongs to and who uploaded it.
Start from the Feature Testing code:
```bash
git clone https://github.com/krishankumar01/goravel-taskflow
cd goravel-taskflow
git checkout part-13
cp .env.example .env
go run . artisan key:generate
go run . artisan jwt:secret
docker compose up -d
go run . artisan migrate:fresh --seed
docker compose exec postgres psql -U goravel -d taskflow -c "CREATE DATABASE taskflow_test"
go install github.com/air-verse/air@latest
air
```
## Where the files go
Goravel saves files through [`facades.Storage()`](https://www.goravel.dev/digging-deeper/filesystem). It saves them to a disk. A disk is a named place for files, set up in `config/filesystems.go`. A new app comes with this config:
```go title="config/filesystems.go" collapse={1-10} collapse={13-16} collapse={20-25}
package config
import (
"github.com/goravel/framework/support/path"
"taskflow/app/facades"
)
func init() {
config := facades.Config()
config.Add("filesystems", map[string]any{
// Default Filesystem Disk
//
// Here you may specify the default filesystem disk that should be used
// by the framework. The "local" disk, as well as a variety of cloud
// based disks are available to your application. Just store away!
"default": "local",
// Filesystem Disks
//
// Here you may configure as many filesystem "disks" as you wish, and you
// may even configure multiple disks of the same driver. Defaults have
// been set up for each driver as an example of the required values.
//
// Supported Drivers: "local", "custom"
"disks": map[string]any{
"local": map[string]any{
"driver": "local",
"root": path.Storage("app"),
},
"public": map[string]any{
"driver": "local",
"root": path.Storage("app/public"),
"url": config.Env("APP_URL", "").(string) + "/storage",
},
},
})
}
```
- `default` is the disk `facades.Storage()` uses when the code doesn't name one. Here it is `local`. To use another disk, name it: `facades.Storage().Disk("public")`.
- Each disk has a driver. The driver decides where the files really go. The `local` driver keeps them in a folder on the server, its `root`. Both disks here use it: `local` saves to `storage/app`, and `public` to `storage/app/public`. Other drivers save files somewhere else, like Amazon S3 (see [S3 and other cloud disks](#more-s3-and-other-cloud-disks) below).
- `public` also has a `url`, for links to its files. It is meant for files anyone can open, like avatars. But no route serves that folder yet: `routes/web.go` only serves `./public`.
Attachments should only reach the people working on the task. So TaskFlow keeps the `local` disk. No route serves `storage/app`, so a file only leaves the server when a handler sends it. And the handler sends it only when the policy allows it.
Make `default` come from `.env`, so the tests can use their own disk. Then add that `testing` disk:
```diff lang="go" title="config/filesystems.go" collapse={1-14} collapse={21-26}
package config
import (
"github.com/goravel/framework/support/path"
"taskflow/app/facades"
)
func init() {
config := facades.Config()
config.Add("filesystems", map[string]any{
// Default Filesystem Disk
//
// Here you may specify the default filesystem disk that should be used
// by the framework. The "local" disk, as well as a variety of cloud
// based disks are available to your application. Just store away!
- "default": "local",
+ "default": config.Env("FILESYSTEM_DISK", "local"),
// Filesystem Disks
//
// Here you may configure as many filesystem "disks" as you wish, and you
// may even configure multiple disks of the same driver. Defaults have
// been set up for each driver as an example of the required values.
//
// Supported Drivers: "local", "custom"
"disks": map[string]any{
"local": map[string]any{
"driver": "local",
"root": path.Storage("app"),
},
+ "testing": map[string]any{
+ "driver": "local",
+ "root": path.Storage("framework/testing/disks/local"),
+ },
"public": map[string]any{
"driver": "local",
"root": path.Storage("app/public"),
"url": config.Env("APP_URL", "").(string) + "/storage",
},
},
})
}
```
Add the setting to `.env` and `.env.example`:
```ini title=".env"
FILESYSTEM_DISK=local
```
S3 and other cloud disks:
TaskFlow stays on the local disk in this series. In production, files usually go to cloud storage, so they are not lost when the server is replaced. Goravel has driver packages for [Amazon S3](https://github.com/goravel/s3), [MinIO](https://github.com/goravel/minio), [Alibaba Cloud OSS](https://github.com/goravel/oss) and [Tencent COS](https://github.com/goravel/cos). For S3:
```bash
go run . artisan package:install github.com/goravel/s3
```
This adds an `s3` disk that reads `AWS_ACCESS_KEY_ID`, `AWS_ACCESS_KEY_SECRET`, `AWS_REGION`, `AWS_BUCKET` and `AWS_URL` from `.env`. It also sets `default` to `"s3"`, replacing the `FILESYSTEM_DISK` line.
The handlers in this part only use `facades.Storage()`, so they work on S3 unchanged. On S3, [`TemporaryUrl`](https://www.goravel.dev/digging-deeper/filesystem#temporary-urls) gives the client a link that expires. On the local disk, it just returns `Url`.
## The attachments table
Each file gets a row in Postgres. The row says which task the file belongs to and who uploaded it. It also keeps the file's original name, its place on the disk, its size and its type:
```go title="app/models/attachment.go"
package models
import (
"github.com/goravel/framework/database/orm"
)
type Attachment struct {
orm.Model
TaskID uint `gorm:"not null" json:"task_id"`
UploadedBy uint `gorm:"not null" json:"uploaded_by"`
Name string `gorm:"size:255;not null" json:"name"`
Path string `gorm:"size:255;not null" json:"-"`
Size int64 `gorm:"not null" json:"size"`
MimeType string `gorm:"size:255;not null" json:"mime_type"`
}
```
`json:"-"` keeps `Path` out of every response, like the password in [Register & Login with JWT](https://devchalk.com/goravel/09-register-login-jwt). Clients don't need to know where a file sits on the disk.
As in [Relationships: Belongs To](https://devchalk.com/goravel/05-relationships-belongs-to), `make:migration -m` only reads registered models, so register `Attachment` next to the others:
```diff lang="go" title="bootstrap/app.go" collapse={1-34} collapse={41-69}
package bootstrap
import (
"context"
"strings"
"github.com/goravel/framework/auth/access"
contractsaccess "github.com/goravel/framework/contracts/auth/access"
"github.com/goravel/framework/contracts/database/schema"
contractsfoundation "github.com/goravel/framework/contracts/foundation"
"github.com/goravel/framework/contracts/http"
"github.com/goravel/framework/foundation"
"github.com/goravel/framework/http/limit"
"taskflow/app/enums"
"taskflow/app/facades"
"taskflow/app/http/authuser"
"taskflow/app/models"
"taskflow/app/policies"
"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{}},
+ Models: []any{models.User{}, models.Task{}, models.Attachment{}},
})
facades.RateLimiter().For("login", func(ctx http.Context) http.Limit {
email := strings.ToLower(strings.TrimSpace(ctx.Request().Input("email")))
return limit.PerMinute(5).By(email).Response(func(ctx http.Context) {
ctx.Response().Json(429, map[string]any{
"error": "Too many login attempts. Try again in a minute.",
}).Abort()
})
})
facades.Gate().Before(func(ctx context.Context, ability string, arguments map[string]any) contractsaccess.Response {
if user := authuser.From(ctx); user != nil && user.Role == enums.RoleManager {
return access.NewAllowResponse()
}
return nil
})
facades.Gate().Define(policies.ViewUsers, func(ctx context.Context, arguments map[string]any) contractsaccess.Response {
return access.NewDenyResponse("Only managers can see the users.")
})
taskPolicy := policies.NewTaskPolicy()
facades.Gate().Define(policies.CreateTask, taskPolicy.Create)
facades.Gate().Define(policies.UpdateTask, taskPolicy.Update)
facades.Gate().Define(policies.DeleteTask, taskPolicy.Delete)
}).
Create()
}
```
Then generate the migration from the model:
```bash
$ go run . artisan make:migration create_attachments_table -m Attachment
```
The columns come from the model, but the foreign keys don't, so add them. `CascadeOnDelete` on `task_id` tells Postgres to delete a task's attachment rows when the task is deleted. `uploaded_by` gets a plain foreign key, like `created_by` on tasks:
```diff lang="go" title="database/migrations/20261026070447_create_attachments_table.go" collapse={1-16} collapse={35-41}
package migrations
import (
"github.com/goravel/framework/contracts/database/schema"
"taskflow/app/facades"
)
type M20261026070447CreateAttachmentsTable struct{}
// Signature The unique signature for the migration.
func (r *M20261026070447CreateAttachmentsTable) Signature() string {
return "20261026070447_create_attachments_table"
}
// Up Run the migrations.
func (r *M20261026070447CreateAttachmentsTable) Up() error {
if !facades.Schema().HasTable("attachments") {
return facades.Schema().Create("attachments", func(table schema.Blueprint) {
table.TimestampTz("created_at").Nullable()
table.TimestampTz("updated_at").Nullable()
table.BigIncrements("id")
table.UnsignedBigInteger("task_id")
table.UnsignedBigInteger("uploaded_by")
table.String("name", 255)
table.String("path", 255)
table.BigInteger("size")
table.String("mime_type", 255)
+
+ table.Foreign("task_id").References("id").On("tasks").CascadeOnDelete()
+ table.Foreign("uploaded_by").References("id").On("users")
})
}
return nil
}
// Down Reverse the migrations.
func (r *M20261026070447CreateAttachmentsTable) Down() error {
return facades.Schema().DropIfExists("attachments")
}
```
Run it. `db:table` shows the two foreign keys. `cascade` on `task_id` means deleting a task deletes its rows:
```bash
$ go run . artisan migrate
$ go run . artisan db:table attachments
…
Foreign Key .......................................... On Update / On Delete
attachments_task_id_foreign task_id references id on tasks no action / cascade
attachments_uploaded_by_foreign uploaded_by references id on users no action / no action
```
A task has many attachments, so `Task` gets the other side, as in [Relationships: Has Many](https://devchalk.com/goravel/06-relationships-has-many):
```diff lang="go" title="app/models/task.go" collapse={1-19}
package models
import (
"github.com/goravel/framework/contracts/database/factory"
"github.com/goravel/framework/database/orm"
"github.com/goravel/framework/support/carbon"
"taskflow/app/enums"
"taskflow/database/factories"
)
type Task struct {
orm.Model
CreatedBy uint `gorm:"not null" json:"created_by"`
Creator *User `gorm:"foreignKey:CreatedBy" json:"creator"`
AssignedTo *uint `json:"assigned_to"`
Assignee *User `gorm:"foreignKey:AssignedTo" json:"assignee"`
Title string `gorm:"size:255;not null" json:"title"`
Description string `gorm:"type:text;not null" json:"description"`
Status enums.Status `gorm:"type:enum('pending','in-progress','done');default:'pending';not null" json:"status"`
DueAt *carbon.DateTime `json:"due_at"`
+ Attachments []Attachment `json:"attachments,omitempty"`
}
func (t *Task) Factory() factory.Factory {
return &factories.TaskFactory{}
}
```
`omitempty` leaves `attachments` out of the JSON when it is empty. Only the single-task response loads the files, so the task list doesn't send `"attachments":null`.
## Who can attach files
The rules follow the task rules from [Gates & Policies](https://devchalk.com/goravel/12-gates-policies). Whoever works on a task can add and download its files. A member can only delete files they uploaded:
| | manager | member |
|---|---|---|
| attach a file to a task | yes | own tasks only |
| download a file | yes | own tasks only |
| delete a file | yes | files they uploaded |
Members still see which files a task has, because they can read every task. Only the task's assignee and managers can open them.
Ana is a manager, and `Before` already lets managers through. So the policy only decides for members:
```go title="app/policies/attachment_policy.go"
package policies
import (
"context"
"taskflow/app/http/authuser"
"taskflow/app/models"
"github.com/goravel/framework/auth/access"
contractsaccess "github.com/goravel/framework/contracts/auth/access"
)
type AttachmentPolicy struct {
}
func NewAttachmentPolicy() *AttachmentPolicy {
return &AttachmentPolicy{}
}
func (r *AttachmentPolicy) Create(ctx context.Context, arguments map[string]any) contractsaccess.Response {
user := authuser.From(ctx)
task := arguments["task"].(*models.Task)
if user == nil || task.AssignedTo == nil || *task.AssignedTo != user.ID {
return access.NewDenyResponse("You can only attach files to tasks assigned to you.")
}
return access.NewAllowResponse()
}
func (r *AttachmentPolicy) View(ctx context.Context, arguments map[string]any) contractsaccess.Response {
user := authuser.From(ctx)
task := arguments["task"].(*models.Task)
if user == nil || task.AssignedTo == nil || *task.AssignedTo != user.ID {
return access.NewDenyResponse("You can only open files on tasks assigned to you.")
}
return access.NewAllowResponse()
}
func (r *AttachmentPolicy) Delete(ctx context.Context, arguments map[string]any) contractsaccess.Response {
user := authuser.From(ctx)
attachment := arguments["attachment"].(*models.Attachment)
if user == nil || attachment.UploadedBy != user.ID {
return access.NewDenyResponse("You can only delete files you uploaded.")
}
return access.NewAllowResponse()
}
```
Name the three abilities:
```diff lang="go" title="app/policies/abilities.go"
package policies
const (
ViewUsers = "view-users"
CreateTask = "create-task"
UpdateTask = "update-task"
DeleteTask = "delete-task"
+
+ CreateAttachment = "create-attachment"
+ ViewAttachment = "view-attachment"
+ DeleteAttachment = "delete-attachment"
)
```
And register them in `bootstrap/app.go`:
```diff lang="go" title="bootstrap/app.go" collapse={1-63}
package bootstrap
import (
"context"
"strings"
"github.com/goravel/framework/auth/access"
contractsaccess "github.com/goravel/framework/contracts/auth/access"
"github.com/goravel/framework/contracts/database/schema"
contractsfoundation "github.com/goravel/framework/contracts/foundation"
"github.com/goravel/framework/contracts/http"
"github.com/goravel/framework/foundation"
"github.com/goravel/framework/http/limit"
"taskflow/app/enums"
"taskflow/app/facades"
"taskflow/app/http/authuser"
"taskflow/app/models"
"taskflow/app/policies"
"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{}, models.Attachment{}},
})
facades.RateLimiter().For("login", func(ctx http.Context) http.Limit {
email := strings.ToLower(strings.TrimSpace(ctx.Request().Input("email")))
return limit.PerMinute(5).By(email).Response(func(ctx http.Context) {
ctx.Response().Json(429, map[string]any{
"error": "Too many login attempts. Try again in a minute.",
}).Abort()
})
})
facades.Gate().Before(func(ctx context.Context, ability string, arguments map[string]any) contractsaccess.Response {
if user := authuser.From(ctx); user != nil && user.Role == enums.RoleManager {
return access.NewAllowResponse()
}
return nil
})
facades.Gate().Define(policies.ViewUsers, func(ctx context.Context, arguments map[string]any) contractsaccess.Response {
return access.NewDenyResponse("Only managers can see the users.")
})
taskPolicy := policies.NewTaskPolicy()
facades.Gate().Define(policies.CreateTask, taskPolicy.Create)
facades.Gate().Define(policies.UpdateTask, taskPolicy.Update)
facades.Gate().Define(policies.DeleteTask, taskPolicy.Delete)
+
+ attachmentPolicy := policies.NewAttachmentPolicy()
+ facades.Gate().Define(policies.CreateAttachment, attachmentPolicy.Create)
+ facades.Gate().Define(policies.ViewAttachment, attachmentPolicy.View)
+ facades.Gate().Define(policies.DeleteAttachment, attachmentPolicy.Delete)
}).
Create()
}
```
## Uploading a file
A file comes in a `multipart/form-data` request. It is what a browser form with a file input sends. Make a form request and a controller for it:
```bash
$ go run . artisan make:request StoreAttachmentRequest
$ go run . artisan make:controller AttachmentController
```
Then three routes under each task:
```diff lang="go" title="routes/api.go" collapse={1-13} collapse={20-30}
package routes
import (
"taskflow/app/facades"
"taskflow/app/http/controllers"
"taskflow/app/http/middleware"
"github.com/goravel/framework/contracts/route"
httpmiddleware "github.com/goravel/framework/http/middleware"
)
func Api() {
authController := controllers.NewAuthController()
taskController := controllers.NewTaskController()
userController := controllers.NewUserController()
+ attachmentController := controllers.NewAttachmentController()
facades.Route().Prefix("api/v1").Group(func(router route.Router) {
router.Post("register", authController.Register)
router.Middleware(httpmiddleware.Throttle("login")).Post("login", authController.Login)
router.Post("refresh", authController.Refresh)
router.Middleware(&middleware.Auth{}).Group(func(router route.Router) {
router.Get("me", authController.Me)
router.Post("logout", authController.Logout)
router.Post("tasks", taskController.Store).Name("tasks.create")
router.Get("tasks/{id}", taskController.Show)
router.Get("tasks", taskController.Index)
router.Patch("tasks/{id}", taskController.Update)
router.Delete("tasks/{id}", taskController.Destroy)
+ router.Post("tasks/{id}/attachments", attachmentController.Store)
+ router.Get("tasks/{id}/attachments/{attachment}", attachmentController.Show)
+ router.Delete("tasks/{id}/attachments/{attachment}", attachmentController.Destroy)
+
router.Get("users", userController.Index)
router.Get("users/{id}", userController.Show)
})
})
}
```
### The rules for a file
The form request checks the file before the handler saves it:
```go title="app/http/requests/store_attachment_request.go" t1:field=/"(file)":/
package requests
import (
"mime/multipart"
"github.com/goravel/framework/contracts/http"
)
type StoreAttachmentRequest struct {
File *multipart.FileHeader `form:"file" json:"file"`
}
func (r *StoreAttachmentRequest) Authorize(ctx http.Context) error {
return nil
}
func (r *StoreAttachmentRequest) Rules(ctx http.Context) map[string]any {
return map[string]any{
"file": "required|file|max:5120|mimes:png,jpg,pdf,txt",
}
}
```
- `File` holds the upload. Its type is Go's `*multipart.FileHeader`. It has the file's name and size, and can open the file.
- `file` passes only when a file came in the `file` field.
- `max:5120` is in kilobytes when the value is a file, so the limit is 5 MB. For a string, the same rule counts characters.
- `mimes:png,jpg,pdf,txt` looks at the first bytes of the file to find its real type. A shell script renamed to `photo.png` still fails, because its first line shows it is a script. A plain text file passes with any name, because `txt` is on the list. `Store` then saves it as `.txt`. The [`extensions`](https://www.goravel.dev/the-basics/validation#available-validation-rules) rule only checks the end of the name, so it would let the script through.
### The handler
The handler loads the task, asks the policy, runs the rules, and only then saves the file:
```go title="app/http/controllers/attachment_controller.go" collapse={121-214} t1:field=/request[.](File)/ t2:path=/"(attachments\/%d)"/ t3:name=/(GetClientOriginalName)/
package controllers
import (
"errors"
"fmt"
"mime"
"os"
"taskflow/app/facades"
"taskflow/app/http/authuser"
"taskflow/app/http/requests"
"taskflow/app/models"
"taskflow/app/policies"
"github.com/goravel/framework/contracts/http"
frameworkerrors "github.com/goravel/framework/errors"
"github.com/goravel/framework/filesystem"
)
type AttachmentController struct {
// Dependent services
}
func NewAttachmentController() *AttachmentController {
return &AttachmentController{
// Inject services
}
}
func (r *AttachmentController) Store(ctx http.Context) http.Response {
var task models.Task
err := facades.Orm().WithContext(ctx).Query().FindOrFail(&task, ctx.Request().RouteInt("id"))
if errors.Is(err, frameworkerrors.OrmRecordNotFound) {
return ctx.Response().Json(404, map[string]any{
"error": "The task does not exist.",
})
}
if err != nil {
facades.Log().Error(err)
return ctx.Response().Json(500, map[string]any{
"error": "Could not load the task.",
})
}
response := facades.Gate().WithContext(ctx).Inspect(policies.CreateAttachment, map[string]any{
"task": &task,
})
if !response.Allowed() {
return ctx.Response().Json(403, map[string]any{
"error": response.Message(),
})
}
var request requests.StoreAttachmentRequest
validationErrors, err := ctx.Request().ValidateRequest(&request)
if err != nil {
facades.Log().Error(err)
return ctx.Response().Json(500, map[string]any{
"error": "Could not save the file.",
})
}
if validationErrors != nil {
return ctx.Response().Json(422, map[string]any{
"errors": validationErrors.All(),
})
}
file, err := filesystem.NewFileFromRequest(request.File)
if err != nil {
facades.Log().Error(err)
return ctx.Response().Json(500, map[string]any{
"error": "Could not save the file.",
})
}
defer os.Remove(file.File())
size, err := file.Size()
if err != nil {
facades.Log().Error(err)
return ctx.Response().Json(500, map[string]any{
"error": "Could not save the file.",
})
}
mimeType, err := file.MimeType()
if err != nil {
facades.Log().Error(err)
return ctx.Response().Json(500, map[string]any{
"error": "Could not save the file.",
})
}
path, err := file.Store(fmt.Sprintf("attachments/%d", task.ID))
if err != nil {
facades.Log().Error(err)
return ctx.Response().Json(500, map[string]any{
"error": "Could not save the file.",
})
}
attachment := models.Attachment{
TaskID: task.ID,
UploadedBy: authuser.From(ctx).ID,
Name: file.GetClientOriginalName(),
Path: path,
Size: size,
MimeType: mimeType,
}
if err := facades.Orm().WithContext(ctx).Query().Create(&attachment); err != nil {
facades.Log().Error(err)
if err := facades.Storage().Delete(path); err != nil {
facades.Log().Error(err)
}
return ctx.Response().Json(500, map[string]any{
"error": "Could not save the file.",
})
}
return ctx.Response().Json(201, attachment)
}
func (r *AttachmentController) Show(ctx http.Context) http.Response {
var task models.Task
err := facades.Orm().WithContext(ctx).Query().FindOrFail(&task, ctx.Request().RouteInt("id"))
if errors.Is(err, frameworkerrors.OrmRecordNotFound) {
return ctx.Response().Json(404, map[string]any{
"error": "The task does not exist.",
})
}
if err != nil {
facades.Log().Error(err)
return ctx.Response().Json(500, map[string]any{
"error": "Could not load the task.",
})
}
response := facades.Gate().WithContext(ctx).Inspect(policies.ViewAttachment, map[string]any{
"task": &task,
})
if !response.Allowed() {
return ctx.Response().Json(403, map[string]any{
"error": response.Message(),
})
}
var attachment models.Attachment
err = facades.Orm().WithContext(ctx).Query().
Where("task_id", task.ID).
FindOrFail(&attachment, ctx.Request().RouteInt("attachment"))
if errors.Is(err, frameworkerrors.OrmRecordNotFound) {
return ctx.Response().Json(404, map[string]any{
"error": "The file does not exist.",
})
}
if err != nil {
facades.Log().Error(err)
return ctx.Response().Json(500, map[string]any{
"error": "Could not load the file.",
})
}
content, err := facades.Storage().GetBytes(attachment.Path)
if err != nil {
facades.Log().Error(err)
return ctx.Response().Json(500, map[string]any{
"error": "Could not load the file.",
})
}
disposition := mime.FormatMediaType("attachment", map[string]string{"filename": attachment.Name})
return ctx.Response().
Header("Content-Disposition", disposition).
Data(200, attachment.MimeType, content)
}
func (r *AttachmentController) Destroy(ctx http.Context) http.Response {
var attachment models.Attachment
err := facades.Orm().WithContext(ctx).Query().
Where("task_id", ctx.Request().RouteInt("id")).
FindOrFail(&attachment, ctx.Request().RouteInt("attachment"))
if errors.Is(err, frameworkerrors.OrmRecordNotFound) {
return ctx.Response().Json(404, map[string]any{
"error": "The file does not exist.",
})
}
if err != nil {
facades.Log().Error(err)
return ctx.Response().Json(500, map[string]any{
"error": "Could not load the file.",
})
}
response := facades.Gate().WithContext(ctx).Inspect(policies.DeleteAttachment, map[string]any{
"attachment": &attachment,
})
if !response.Allowed() {
return ctx.Response().Json(403, map[string]any{
"error": response.Message(),
})
}
if _, err := facades.Orm().WithContext(ctx).Query().Delete(&attachment); err != nil {
facades.Log().Error(err)
return ctx.Response().Json(500, map[string]any{
"error": "Could not delete the file.",
})
}
if err := facades.Storage().Delete(attachment.Path); err != nil {
facades.Log().Error(err)
}
return ctx.Response().NoContent()
}
```
- `request.File` is the upload the rules just checked. `filesystem.NewFileFromRequest` turns it into a Goravel file, which has `Size`, `MimeType` and `Store`.
- `Store` saves the file under `attachments/`, with a random 40-character name. The extension comes from the file's content. If the type is unknown, it comes from the client's name. The client's name is never used in the path. So two files called `login-error.png` don't overwrite each other, and a name like `../../.env` can't put a file outside the folder.
- The row saves the client's file name from `GetClientOriginalName`. The download uses it later.
- If the row can't be saved, the handler deletes the file, so a failed upload doesn't leave a file behind.
A file belongs to a task. So first, Ana creates a task for Ben. Then Ben uploads files to it. The setup is the same as in [Gates & Policies](https://devchalk.com/goravel/12-gates-policies#the-rules):
1. Reset the database with `migrate:fresh --seed`. The seeder adds Ana and three members.
2. Register Ben. New accounts are members, and Ben gets the next id, 5.
3. Log in as Ana and as Ben, and save each token in a variable, `ANA` and `BEN`.
4. As Ana, create a task for Ben.
```bash
$ go run . artisan migrate:fresh --seed
$ curl -X POST http://localhost:3000/api/v1/register \
-H "Content-Type: application/json" \
-d '{"name":"Ben","email":"ben@example.com","password":"secret123"}'
{…,"id":5,"name":"Ben",…,"role":"member",…}
$ curl -X POST http://localhost:3000/api/v1/login \
-H "Content-Type: application/json" \
-d '{"email":"ana@example.com","password":"password"}'
{"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9…"}
$ ANA=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9…
$ curl -X POST http://localhost:3000/api/v1/login \
-H "Content-Type: application/json" \
-d '{"email":"ben@example.com","password":"secret123"}'
{"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9…"}
$ BEN=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9…
$ curl -X POST http://localhost:3000/api/v1/tasks \
-H "Authorization: Bearer $ANA" \
-H "Content-Type: application/json" \
-d '{"title":"Fix the login page","description":"The error message overlaps the button.","assigned_to":5}'
{…,"id":8,"created_by":1,…,"assigned_to":5,…,"title":"Fix the login page","description":"The error message overlaps the button.","status":"pending","due_at":null}
```
Ben uploads a screenshot to his task, task 8. Any PNG works; here it's `login-error.png`:
```bash t1:field=/-F (file)=/ t3:name=/"name":"(login-error[.]png)"/
$ curl -X POST http://localhost:3000/api/v1/tasks/8/attachments \
-H "Authorization: Bearer $BEN" \
-F file=@login-error.png
{…,"id":1,"task_id":8,"uploaded_by":5,"name":"login-error.png","size":93,"mime_type":"image/png"}
```
`curl -F` sends a `multipart/form-data` request. `@` tells curl to read the file from disk. The file is now in the task's folder, under a random name:
```bash t2:path=/(attachments\/8)\//
$ find storage/app -type f
storage/app/attachments/8/L1D5yFohfKVrSjYqRYGfCMGvrVz3dBvOMnj9XswQ.png
```
A shell script renamed to `fake.png`, any PDF over 5 MB and a file for someone else's task are all refused:
```bash
$ curl -X POST http://localhost:3000/api/v1/tasks/8/attachments -H "Authorization: Bearer $BEN" -F file=@fake.png
{"errors":{"file":{"mimes":"The file field must be a file of type: png, jpg, pdf, txt."}}}
$ curl -X POST http://localhost:3000/api/v1/tasks/8/attachments -H "Authorization: Bearer $BEN" -F file=@big.pdf
{"errors":{"file":{"max":"The file field must not be greater than 5120 kilobytes."}}}
$ curl -X POST http://localhost:3000/api/v1/tasks/1/attachments -H "Authorization: Bearer $BEN" -F file=@login-error.png
{"error":"You can only attach files to tasks assigned to you."}
```
> **Gotcha** `NewFileFromRequest` copies the upload into Go's temp folder. That is `$TMPDIR` if it is set, and `/tmp` if not. `ctx.Request().File` does the same copy. Goravel never deletes it, so the handler deletes it with `defer os.Remove(file.File())`. Without that line, each upload leaves one file in the temp folder. Its name starts with `APP_NAME`. After three uploads:
>
> ```bash
> $ ls $TMPDIR | grep Goravel_
> Goravel_2906077324.png
> Goravel_3355795916.png
> Goravel_438708143.png
> ```
>
> These files stay after the request ends, and even after the server stops. On a busy server, they fill up the disk. This happens with both HTTP drivers, Gin and Fiber.
body_limit doesn't limit the upload:
`config/http.go` has `body_limit: 4096`. It looks like a 4 MB limit, but it isn't one. Goravel reads the whole form the first time anything reads the request. In TaskFlow, that is the auth middleware, when it reads the `Authorization` header. It keeps up to 32 MB in memory and writes the rest to temp files. Go deletes those after the request. Gin only uses `body_limit` to decide how much of a form stays in memory, and by then Goravel has already read the form. So it never limits anything. So a 50 MB upload is read in full, even with no token:
```bash
$ curl -X POST http://localhost:3000/api/v1/tasks/8/attachments -H "Authorization: Bearer $BEN" -F file=@huge.pdf \
-w ' [%{http_code}] sent %{size_upload} bytes\n'
{"errors":{"file":{"max":"The file field must not be greater than 5120 kilobytes."}}} [422] sent 52429012 bytes
$ curl -X POST http://localhost:3000/api/v1/tasks/8/attachments -F file=@huge.pdf \
-w ' [%{http_code}] sent %{size_upload} bytes\n'
{"error":"authentication token is invalid"} [401] sent 52429012 bytes
```
To stop a large upload before the app reads it, limit the request size in front of the app. In nginx, that is `client_max_body_size`.
## Downloading a file
A file on the local disk has no public URL. So the client asks the API for it. The handler checks the policy before it sends the file:
```go title="app/http/controllers/attachment_controller.go" collapse={1-120} collapse={176-214} t3:name=/"filename": attachment[.](Name)/
package controllers
import (
"errors"
"fmt"
"mime"
"os"
"taskflow/app/facades"
"taskflow/app/http/authuser"
"taskflow/app/http/requests"
"taskflow/app/models"
"taskflow/app/policies"
"github.com/goravel/framework/contracts/http"
frameworkerrors "github.com/goravel/framework/errors"
"github.com/goravel/framework/filesystem"
)
type AttachmentController struct {
// Dependent services
}
func NewAttachmentController() *AttachmentController {
return &AttachmentController{
// Inject services
}
}
func (r *AttachmentController) Store(ctx http.Context) http.Response {
var task models.Task
err := facades.Orm().WithContext(ctx).Query().FindOrFail(&task, ctx.Request().RouteInt("id"))
if errors.Is(err, frameworkerrors.OrmRecordNotFound) {
return ctx.Response().Json(404, map[string]any{
"error": "The task does not exist.",
})
}
if err != nil {
facades.Log().Error(err)
return ctx.Response().Json(500, map[string]any{
"error": "Could not load the task.",
})
}
response := facades.Gate().WithContext(ctx).Inspect(policies.CreateAttachment, map[string]any{
"task": &task,
})
if !response.Allowed() {
return ctx.Response().Json(403, map[string]any{
"error": response.Message(),
})
}
var request requests.StoreAttachmentRequest
validationErrors, err := ctx.Request().ValidateRequest(&request)
if err != nil {
facades.Log().Error(err)
return ctx.Response().Json(500, map[string]any{
"error": "Could not save the file.",
})
}
if validationErrors != nil {
return ctx.Response().Json(422, map[string]any{
"errors": validationErrors.All(),
})
}
file, err := filesystem.NewFileFromRequest(request.File)
if err != nil {
facades.Log().Error(err)
return ctx.Response().Json(500, map[string]any{
"error": "Could not save the file.",
})
}
defer os.Remove(file.File())
size, err := file.Size()
if err != nil {
facades.Log().Error(err)
return ctx.Response().Json(500, map[string]any{
"error": "Could not save the file.",
})
}
mimeType, err := file.MimeType()
if err != nil {
facades.Log().Error(err)
return ctx.Response().Json(500, map[string]any{
"error": "Could not save the file.",
})
}
path, err := file.Store(fmt.Sprintf("attachments/%d", task.ID))
if err != nil {
facades.Log().Error(err)
return ctx.Response().Json(500, map[string]any{
"error": "Could not save the file.",
})
}
attachment := models.Attachment{
TaskID: task.ID,
UploadedBy: authuser.From(ctx).ID,
Name: file.GetClientOriginalName(),
Path: path,
Size: size,
MimeType: mimeType,
}
if err := facades.Orm().WithContext(ctx).Query().Create(&attachment); err != nil {
facades.Log().Error(err)
if err := facades.Storage().Delete(path); err != nil {
facades.Log().Error(err)
}
return ctx.Response().Json(500, map[string]any{
"error": "Could not save the file.",
})
}
return ctx.Response().Json(201, attachment)
}
func (r *AttachmentController) Show(ctx http.Context) http.Response {
var task models.Task
err := facades.Orm().WithContext(ctx).Query().FindOrFail(&task, ctx.Request().RouteInt("id"))
if errors.Is(err, frameworkerrors.OrmRecordNotFound) {
return ctx.Response().Json(404, map[string]any{
"error": "The task does not exist.",
})
}
if err != nil {
facades.Log().Error(err)
return ctx.Response().Json(500, map[string]any{
"error": "Could not load the task.",
})
}
response := facades.Gate().WithContext(ctx).Inspect(policies.ViewAttachment, map[string]any{
"task": &task,
})
if !response.Allowed() {
return ctx.Response().Json(403, map[string]any{
"error": response.Message(),
})
}
var attachment models.Attachment
err = facades.Orm().WithContext(ctx).Query().
Where("task_id", task.ID).
FindOrFail(&attachment, ctx.Request().RouteInt("attachment"))
if errors.Is(err, frameworkerrors.OrmRecordNotFound) {
return ctx.Response().Json(404, map[string]any{
"error": "The file does not exist.",
})
}
if err != nil {
facades.Log().Error(err)
return ctx.Response().Json(500, map[string]any{
"error": "Could not load the file.",
})
}
content, err := facades.Storage().GetBytes(attachment.Path)
if err != nil {
facades.Log().Error(err)
return ctx.Response().Json(500, map[string]any{
"error": "Could not load the file.",
})
}
disposition := mime.FormatMediaType("attachment", map[string]string{"filename": attachment.Name})
return ctx.Response().
Header("Content-Disposition", disposition).
Data(200, attachment.MimeType, content)
}
func (r *AttachmentController) Destroy(ctx http.Context) http.Response {
var attachment models.Attachment
err := facades.Orm().WithContext(ctx).Query().
Where("task_id", ctx.Request().RouteInt("id")).
FindOrFail(&attachment, ctx.Request().RouteInt("attachment"))
if errors.Is(err, frameworkerrors.OrmRecordNotFound) {
return ctx.Response().Json(404, map[string]any{
"error": "The file does not exist.",
})
}
if err != nil {
facades.Log().Error(err)
return ctx.Response().Json(500, map[string]any{
"error": "Could not load the file.",
})
}
response := facades.Gate().WithContext(ctx).Inspect(policies.DeleteAttachment, map[string]any{
"attachment": &attachment,
})
if !response.Allowed() {
return ctx.Response().Json(403, map[string]any{
"error": response.Message(),
})
}
if _, err := facades.Orm().WithContext(ctx).Query().Delete(&attachment); err != nil {
facades.Log().Error(err)
return ctx.Response().Json(500, map[string]any{
"error": "Could not delete the file.",
})
}
if err := facades.Storage().Delete(attachment.Path); err != nil {
facades.Log().Error(err)
}
return ctx.Response().NoContent()
}
```
- `Where("task_id", task.ID)` only finds the file if it belongs to the task in the URL. A file from another task gives a `404`, even for Ana.
- `GetBytes` reads the file through the disk, so it works on the local disk and on S3. `ctx.Response().Download` can also send a file, but it only opens files on the server's own hard drive. With S3, the files aren't on the server, so `Download` would stop working.
- `Content-Disposition: attachment` makes the browser save the file under its original name, instead of opening it. `mime.FormatMediaType` builds the header, so names with spaces or accents still work.
```bash t3:name=/filename=(login-error[.]png)/
$ curl -D - -o /dev/null http://localhost:3000/api/v1/tasks/8/attachments/1 -H "Authorization: Bearer $BEN"
HTTP/1.1 200 OK
Content-Disposition: attachment; filename=login-error.png
Content-Length: 93
Content-Type: image/png
```
File 1 is not under task 1, so even Ana gets a 404:
```bash
$ curl http://localhost:3000/api/v1/tasks/1/attachments/1 -H "Authorization: Bearer $ANA"
{"error":"The file does not exist."}
```
A single task now lists its files. The client gets the file ids from there:
```diff lang="go" title="app/http/controllers/task_controller.go" collapse={1-78} collapse={86-245}
package controllers
import (
"errors"
"taskflow/app/enums"
"taskflow/app/facades"
"taskflow/app/http/authuser"
"taskflow/app/http/requests"
"taskflow/app/models"
"taskflow/app/policies"
"github.com/goravel/framework/contracts/http"
frameworkerrors "github.com/goravel/framework/errors"
"github.com/goravel/framework/validation"
)
type TaskController struct {
// Dependent services
}
func NewTaskController() *TaskController {
return &TaskController{
// Inject services
}
}
func (r *TaskController) Store(ctx http.Context) http.Response {
if response := facades.Gate().WithContext(ctx).Inspect(policies.CreateTask, nil); !response.Allowed() {
return ctx.Response().Json(403, map[string]any{
"error": response.Message(),
})
}
var request requests.StoreTaskRequest
validationErrors, err := ctx.Request().ValidateRequest(&request)
if err != nil {
facades.Log().Error(err)
return ctx.Response().Json(500, map[string]any{
"error": "Could not save the task.",
})
}
if validationErrors != nil {
return ctx.Response().Json(422, map[string]any{
"errors": validationErrors.All(),
})
}
task := models.Task{
CreatedBy: authuser.From(ctx).ID,
AssignedTo: request.AssignedTo,
Title: request.Title,
Description: request.Description,
Status: enums.StatusPending,
DueAt: request.DueAt,
}
if err := facades.Orm().WithContext(ctx).Query().Create(&task); err != nil {
facades.Log().Error(err)
return ctx.Response().Json(500, map[string]any{
"error": "Could not save the task.",
})
}
var saved models.Task
err = facades.Orm().WithContext(ctx).Query().With("Creator").With("Assignee").Find(&saved, task.ID)
if err != nil {
facades.Log().Error(err)
return ctx.Response().Json(500, map[string]any{
"error": "Could not load the task.",
})
}
return ctx.Response().Json(201, saved)
}
func (r *TaskController) Show(ctx http.Context) http.Response {
var task models.Task
err := facades.Orm().WithContext(ctx).Query().
With("Creator").
With("Assignee").
+ With("Attachments").
FindOrFail(&task, ctx.Request().RouteInt("id"))
if errors.Is(err, frameworkerrors.OrmRecordNotFound) {
return ctx.Response().Json(404, map[string]any{
"error": "The task does not exist.",
})
}
if err != nil {
facades.Log().Error(err)
return ctx.Response().Json(500, map[string]any{
"error": "Could not load the task.",
})
}
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",
"per_page": "bail|filled|integer|min:1|max:100",
})
if err != nil {
facades.Log().Error(err)
return ctx.Response().Json(500, map[string]any{
"error": "Could not list the tasks.",
})
}
if validator.Fails() {
return ctx.Response().Json(422, map[string]any{
"errors": validator.Errors().All(),
})
}
page := ctx.Request().QueryInt("page", 1)
perPage := ctx.Request().QueryInt("per_page", 15)
query := facades.Orm().WithContext(ctx).Query()
if status := ctx.Request().Query("status"); status != "" {
query = query.Where("status", status)
}
var tasks []models.Task
var total int64
err = query.With("Creator").With("Assignee").OrderByDesc("id").Paginate(page, perPage, &tasks, &total)
if err != nil {
facades.Log().Error(err)
return ctx.Response().Json(500, map[string]any{
"error": "Could not list the tasks.",
})
}
return ctx.Response().Json(200, map[string]any{
"data": tasks,
"meta": map[string]any{
"page": page,
"per_page": perPage,
"total": total,
},
})
}
func (r *TaskController) Update(ctx http.Context) http.Response {
var task models.Task
err := facades.Orm().WithContext(ctx).Query().FindOrFail(&task, ctx.Request().RouteInt("id"))
if errors.Is(err, frameworkerrors.OrmRecordNotFound) {
return ctx.Response().Json(404, map[string]any{
"error": "The task does not exist.",
})
}
if err != nil {
facades.Log().Error(err)
return ctx.Response().Json(500, map[string]any{
"error": "Could not load the task.",
})
}
var request requests.UpdateTaskRequest
if err := request.Authorize(ctx); err != nil {
facades.Log().Error(err)
return ctx.Response().Json(500, map[string]any{
"error": "Could not save the task.",
})
}
validator, err := ctx.Request().Validate(
request.Rules(ctx),
validation.Filters(request.Filters(ctx)),
validation.Attributes(request.Attributes(ctx)),
)
if err != nil {
facades.Log().Error(err)
return ctx.Response().Json(500, map[string]any{
"error": "Could not save the task.",
})
}
if validator.Fails() {
return ctx.Response().Json(422, map[string]any{
"errors": validator.Errors().All(),
})
}
changes := validator.Validated()
response := facades.Gate().WithContext(ctx).Inspect(policies.UpdateTask, map[string]any{
"task": &task,
"changes": changes,
})
if !response.Allowed() {
return ctx.Response().Json(403, map[string]any{
"error": response.Message(),
})
}
if len(changes) > 0 {
if _, err := facades.Orm().WithContext(ctx).Query().Model(&task).Update(changes); err != nil {
facades.Log().Error(err)
return ctx.Response().Json(500, map[string]any{
"error": "Could not save the task.",
})
}
}
var saved models.Task
err = facades.Orm().WithContext(ctx).Query().With("Creator").With("Assignee").Find(&saved, task.ID)
if err != nil {
facades.Log().Error(err)
return ctx.Response().Json(500, map[string]any{
"error": "Could not load the task.",
})
}
return ctx.Response().Json(200, saved)
}
func (r *TaskController) Destroy(ctx http.Context) http.Response {
var task models.Task
err := facades.Orm().WithContext(ctx).Query().FindOrFail(&task, ctx.Request().RouteInt("id"))
if errors.Is(err, frameworkerrors.OrmRecordNotFound) {
return ctx.Response().Json(404, map[string]any{
"error": "The task does not exist.",
})
}
if err != nil {
facades.Log().Error(err)
return ctx.Response().Json(500, map[string]any{
"error": "Could not load the task.",
})
}
if response := facades.Gate().WithContext(ctx).Inspect(policies.DeleteTask, nil); !response.Allowed() {
return ctx.Response().Json(403, map[string]any{
"error": response.Message(),
})
}
if _, err := facades.Orm().WithContext(ctx).Query().Delete(&task); err != nil {
facades.Log().Error(err)
return ctx.Response().Json(500, map[string]any{
"error": "Could not delete the task.",
})
}
return ctx.Response().NoContent()
}
```
```bash
$ curl http://localhost:3000/api/v1/tasks/8 -H "Authorization: Bearer $BEN"
{…,"title":"Fix the login page",…,"attachments":[{…,"id":1,"task_id":8,"uploaded_by":5,"name":"login-error.png","size":93,"mime_type":"image/png"}]}
```
## Deleting files
A file has two parts: the row in Postgres and the file on the disk. Deleting it removes both:
```go title="app/http/controllers/attachment_controller.go" collapse={1-175}
package controllers
import (
"errors"
"fmt"
"mime"
"os"
"taskflow/app/facades"
"taskflow/app/http/authuser"
"taskflow/app/http/requests"
"taskflow/app/models"
"taskflow/app/policies"
"github.com/goravel/framework/contracts/http"
frameworkerrors "github.com/goravel/framework/errors"
"github.com/goravel/framework/filesystem"
)
type AttachmentController struct {
// Dependent services
}
func NewAttachmentController() *AttachmentController {
return &AttachmentController{
// Inject services
}
}
func (r *AttachmentController) Store(ctx http.Context) http.Response {
var task models.Task
err := facades.Orm().WithContext(ctx).Query().FindOrFail(&task, ctx.Request().RouteInt("id"))
if errors.Is(err, frameworkerrors.OrmRecordNotFound) {
return ctx.Response().Json(404, map[string]any{
"error": "The task does not exist.",
})
}
if err != nil {
facades.Log().Error(err)
return ctx.Response().Json(500, map[string]any{
"error": "Could not load the task.",
})
}
response := facades.Gate().WithContext(ctx).Inspect(policies.CreateAttachment, map[string]any{
"task": &task,
})
if !response.Allowed() {
return ctx.Response().Json(403, map[string]any{
"error": response.Message(),
})
}
var request requests.StoreAttachmentRequest
validationErrors, err := ctx.Request().ValidateRequest(&request)
if err != nil {
facades.Log().Error(err)
return ctx.Response().Json(500, map[string]any{
"error": "Could not save the file.",
})
}
if validationErrors != nil {
return ctx.Response().Json(422, map[string]any{
"errors": validationErrors.All(),
})
}
file, err := filesystem.NewFileFromRequest(request.File)
if err != nil {
facades.Log().Error(err)
return ctx.Response().Json(500, map[string]any{
"error": "Could not save the file.",
})
}
defer os.Remove(file.File())
size, err := file.Size()
if err != nil {
facades.Log().Error(err)
return ctx.Response().Json(500, map[string]any{
"error": "Could not save the file.",
})
}
mimeType, err := file.MimeType()
if err != nil {
facades.Log().Error(err)
return ctx.Response().Json(500, map[string]any{
"error": "Could not save the file.",
})
}
path, err := file.Store(fmt.Sprintf("attachments/%d", task.ID))
if err != nil {
facades.Log().Error(err)
return ctx.Response().Json(500, map[string]any{
"error": "Could not save the file.",
})
}
attachment := models.Attachment{
TaskID: task.ID,
UploadedBy: authuser.From(ctx).ID,
Name: file.GetClientOriginalName(),
Path: path,
Size: size,
MimeType: mimeType,
}
if err := facades.Orm().WithContext(ctx).Query().Create(&attachment); err != nil {
facades.Log().Error(err)
if err := facades.Storage().Delete(path); err != nil {
facades.Log().Error(err)
}
return ctx.Response().Json(500, map[string]any{
"error": "Could not save the file.",
})
}
return ctx.Response().Json(201, attachment)
}
func (r *AttachmentController) Show(ctx http.Context) http.Response {
var task models.Task
err := facades.Orm().WithContext(ctx).Query().FindOrFail(&task, ctx.Request().RouteInt("id"))
if errors.Is(err, frameworkerrors.OrmRecordNotFound) {
return ctx.Response().Json(404, map[string]any{
"error": "The task does not exist.",
})
}
if err != nil {
facades.Log().Error(err)
return ctx.Response().Json(500, map[string]any{
"error": "Could not load the task.",
})
}
response := facades.Gate().WithContext(ctx).Inspect(policies.ViewAttachment, map[string]any{
"task": &task,
})
if !response.Allowed() {
return ctx.Response().Json(403, map[string]any{
"error": response.Message(),
})
}
var attachment models.Attachment
err = facades.Orm().WithContext(ctx).Query().
Where("task_id", task.ID).
FindOrFail(&attachment, ctx.Request().RouteInt("attachment"))
if errors.Is(err, frameworkerrors.OrmRecordNotFound) {
return ctx.Response().Json(404, map[string]any{
"error": "The file does not exist.",
})
}
if err != nil {
facades.Log().Error(err)
return ctx.Response().Json(500, map[string]any{
"error": "Could not load the file.",
})
}
content, err := facades.Storage().GetBytes(attachment.Path)
if err != nil {
facades.Log().Error(err)
return ctx.Response().Json(500, map[string]any{
"error": "Could not load the file.",
})
}
disposition := mime.FormatMediaType("attachment", map[string]string{"filename": attachment.Name})
return ctx.Response().
Header("Content-Disposition", disposition).
Data(200, attachment.MimeType, content)
}
func (r *AttachmentController) Destroy(ctx http.Context) http.Response {
var attachment models.Attachment
err := facades.Orm().WithContext(ctx).Query().
Where("task_id", ctx.Request().RouteInt("id")).
FindOrFail(&attachment, ctx.Request().RouteInt("attachment"))
if errors.Is(err, frameworkerrors.OrmRecordNotFound) {
return ctx.Response().Json(404, map[string]any{
"error": "The file does not exist.",
})
}
if err != nil {
facades.Log().Error(err)
return ctx.Response().Json(500, map[string]any{
"error": "Could not load the file.",
})
}
response := facades.Gate().WithContext(ctx).Inspect(policies.DeleteAttachment, map[string]any{
"attachment": &attachment,
})
if !response.Allowed() {
return ctx.Response().Json(403, map[string]any{
"error": response.Message(),
})
}
if _, err := facades.Orm().WithContext(ctx).Query().Delete(&attachment); err != nil {
facades.Log().Error(err)
return ctx.Response().Json(500, map[string]any{
"error": "Could not delete the file.",
})
}
if err := facades.Storage().Delete(attachment.Path); err != nil {
facades.Log().Error(err)
}
return ctx.Response().NoContent()
}
```
The row is deleted first. If deleting the file fails after that, the client still gets `204`, and the error goes to the log. A file with no row only wastes space. A row with no file would be a broken download.
Ana uploads a text file, `notes.txt`, to Ben's task. Ben can't delete her file, but he can delete his own:
```bash
$ curl -X POST http://localhost:3000/api/v1/tasks/8/attachments -H "Authorization: Bearer $ANA" -F file=@notes.txt
{…,"id":2,"task_id":8,"uploaded_by":1,"name":"notes.txt","size":19,"mime_type":"text/plain; charset=utf-8"}
$ curl -X DELETE http://localhost:3000/api/v1/tasks/8/attachments/2 -H "Authorization: Bearer $BEN"
{"error":"You can only delete files you uploaded."}
$ curl -i -X DELETE http://localhost:3000/api/v1/tasks/8/attachments/1 -H "Authorization: Bearer $BEN"
HTTP/1.1 204 No Content
```
### When a task is deleted
`CascadeOnDelete` deletes a task's rows, but Postgres doesn't know about the disk. Ana deletes task 8. Its row is gone, but `notes.txt` is still in its folder:
```bash
$ curl -X DELETE http://localhost:3000/api/v1/tasks/8 -H "Authorization: Bearer $ANA"
$ docker compose exec postgres psql -U goravel taskflow -c "select count(*) from attachments where task_id = 8"
count
-------
0
(1 row)
$ find storage/app/attachments -type f
storage/app/attachments/8/MyNSQ6l8gjPiwTvZA7JO9qZ6OH5EurXPcJrYy62K.txt
```
Each task's files are in one folder. So the task's `Destroy` deletes that folder after the row:
```diff lang="go" title="app/http/controllers/task_controller.go" collapse={8-216} collapse={219-236}
package controllers
import (
"errors"
+ "fmt"
"taskflow/app/enums"
"taskflow/app/facades"
"taskflow/app/http/authuser"
"taskflow/app/http/requests"
"taskflow/app/models"
"taskflow/app/policies"
"github.com/goravel/framework/contracts/http"
frameworkerrors "github.com/goravel/framework/errors"
"github.com/goravel/framework/validation"
)
type TaskController struct {
// Dependent services
}
func NewTaskController() *TaskController {
return &TaskController{
// Inject services
}
}
func (r *TaskController) Store(ctx http.Context) http.Response {
if response := facades.Gate().WithContext(ctx).Inspect(policies.CreateTask, nil); !response.Allowed() {
return ctx.Response().Json(403, map[string]any{
"error": response.Message(),
})
}
var request requests.StoreTaskRequest
validationErrors, err := ctx.Request().ValidateRequest(&request)
if err != nil {
facades.Log().Error(err)
return ctx.Response().Json(500, map[string]any{
"error": "Could not save the task.",
})
}
if validationErrors != nil {
return ctx.Response().Json(422, map[string]any{
"errors": validationErrors.All(),
})
}
task := models.Task{
CreatedBy: authuser.From(ctx).ID,
AssignedTo: request.AssignedTo,
Title: request.Title,
Description: request.Description,
Status: enums.StatusPending,
DueAt: request.DueAt,
}
if err := facades.Orm().WithContext(ctx).Query().Create(&task); err != nil {
facades.Log().Error(err)
return ctx.Response().Json(500, map[string]any{
"error": "Could not save the task.",
})
}
var saved models.Task
err = facades.Orm().WithContext(ctx).Query().With("Creator").With("Assignee").Find(&saved, task.ID)
if err != nil {
facades.Log().Error(err)
return ctx.Response().Json(500, map[string]any{
"error": "Could not load the task.",
})
}
return ctx.Response().Json(201, saved)
}
func (r *TaskController) Show(ctx http.Context) http.Response {
var task models.Task
err := facades.Orm().WithContext(ctx).Query().
With("Creator").
With("Assignee").
With("Attachments").
FindOrFail(&task, ctx.Request().RouteInt("id"))
if errors.Is(err, frameworkerrors.OrmRecordNotFound) {
return ctx.Response().Json(404, map[string]any{
"error": "The task does not exist.",
})
}
if err != nil {
facades.Log().Error(err)
return ctx.Response().Json(500, map[string]any{
"error": "Could not load the task.",
})
}
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",
"per_page": "bail|filled|integer|min:1|max:100",
})
if err != nil {
facades.Log().Error(err)
return ctx.Response().Json(500, map[string]any{
"error": "Could not list the tasks.",
})
}
if validator.Fails() {
return ctx.Response().Json(422, map[string]any{
"errors": validator.Errors().All(),
})
}
page := ctx.Request().QueryInt("page", 1)
perPage := ctx.Request().QueryInt("per_page", 15)
query := facades.Orm().WithContext(ctx).Query()
if status := ctx.Request().Query("status"); status != "" {
query = query.Where("status", status)
}
var tasks []models.Task
var total int64
err = query.With("Creator").With("Assignee").OrderByDesc("id").Paginate(page, perPage, &tasks, &total)
if err != nil {
facades.Log().Error(err)
return ctx.Response().Json(500, map[string]any{
"error": "Could not list the tasks.",
})
}
return ctx.Response().Json(200, map[string]any{
"data": tasks,
"meta": map[string]any{
"page": page,
"per_page": perPage,
"total": total,
},
})
}
func (r *TaskController) Update(ctx http.Context) http.Response {
var task models.Task
err := facades.Orm().WithContext(ctx).Query().FindOrFail(&task, ctx.Request().RouteInt("id"))
if errors.Is(err, frameworkerrors.OrmRecordNotFound) {
return ctx.Response().Json(404, map[string]any{
"error": "The task does not exist.",
})
}
if err != nil {
facades.Log().Error(err)
return ctx.Response().Json(500, map[string]any{
"error": "Could not load the task.",
})
}
var request requests.UpdateTaskRequest
if err := request.Authorize(ctx); err != nil {
facades.Log().Error(err)
return ctx.Response().Json(500, map[string]any{
"error": "Could not save the task.",
})
}
validator, err := ctx.Request().Validate(
request.Rules(ctx),
validation.Filters(request.Filters(ctx)),
validation.Attributes(request.Attributes(ctx)),
)
if err != nil {
facades.Log().Error(err)
return ctx.Response().Json(500, map[string]any{
"error": "Could not save the task.",
})
}
if validator.Fails() {
return ctx.Response().Json(422, map[string]any{
"errors": validator.Errors().All(),
})
}
changes := validator.Validated()
response := facades.Gate().WithContext(ctx).Inspect(policies.UpdateTask, map[string]any{
"task": &task,
"changes": changes,
})
if !response.Allowed() {
return ctx.Response().Json(403, map[string]any{
"error": response.Message(),
})
}
if len(changes) > 0 {
if _, err := facades.Orm().WithContext(ctx).Query().Model(&task).Update(changes); err != nil {
facades.Log().Error(err)
return ctx.Response().Json(500, map[string]any{
"error": "Could not save the task.",
})
}
}
var saved models.Task
err = facades.Orm().WithContext(ctx).Query().With("Creator").With("Assignee").Find(&saved, task.ID)
if err != nil {
facades.Log().Error(err)
return ctx.Response().Json(500, map[string]any{
"error": "Could not load the task.",
})
}
return ctx.Response().Json(200, saved)
}
func (r *TaskController) Destroy(ctx http.Context) http.Response {
var task models.Task
err := facades.Orm().WithContext(ctx).Query().FindOrFail(&task, ctx.Request().RouteInt("id"))
if errors.Is(err, frameworkerrors.OrmRecordNotFound) {
return ctx.Response().Json(404, map[string]any{
"error": "The task does not exist.",
})
}
if err != nil {
facades.Log().Error(err)
return ctx.Response().Json(500, map[string]any{
"error": "Could not load the task.",
})
}
if response := facades.Gate().WithContext(ctx).Inspect(policies.DeleteTask, nil); !response.Allowed() {
return ctx.Response().Json(403, map[string]any{
"error": response.Message(),
})
}
if _, err := facades.Orm().WithContext(ctx).Query().Delete(&task); err != nil {
facades.Log().Error(err)
return ctx.Response().Json(500, map[string]any{
"error": "Could not delete the task.",
})
}
+ if err := facades.Storage().DeleteDirectory(fmt.Sprintf("attachments/%d", task.ID)); err != nil {
+ facades.Log().Error(err)
+ }
+
return ctx.Response().NoContent()
}
```
Here is the whole trip, from the upload to the delete:
*An upload, a download and a task delete, one message at a time.*
Upload: the client sends the file as multipart/form-data, the API checks the policy and the rules, copies the file into the temp folder, stores it on the disk under attachments/8 with a random name, inserts a row in Postgres, removes the temp copy and answers 201. Download: the API loads the row for the task, reads the file from the disk and sends it with a Content-Disposition header. Delete the task: Postgres deletes the task and, through the cascade, its attachment rows, and the API deletes the folder attachments/8 from the disk.
## Tests
The tests for this part are in [`tests/feature/attachment_test.go`](https://github.com/krishankumar01/goravel-taskflow/blob/part-14/tests/feature/attachment_test.go) in TaskFlow. They upload, download and delete files, check every rule in the table above, and check that no temp file is left behind. They run on the `testing` disk:
```diff lang="bash" title=".env.testing" collapse={1-28}
APP_NAME=Goravel
APP_ENV=testing
APP_KEY=2Nr9bpCIal8Dj0qYE7IviHcYcwzmVqoh
APP_DEBUG=true
LOG_CHANNEL=stack
LOG_LEVEL=debug
SESSION_DRIVER=file
SESSION_LIFETIME=120
APP_URL=http://localhost
APP_HOST=127.0.0.1
APP_PORT=3000
JWT_SECRET=xRQKZ4WHIu8bIHANdQVnskdTsa47cya8
DB_CONNECTION=postgres
DB_HOST=127.0.0.1
DB_PORT=5433
DB_DATABASE=taskflow_test
DB_USERNAME=goravel
DB_PASSWORD=secret
REDIS_HOST=127.0.0.1
REDIS_PASSWORD=
REDIS_PORT=6380
CACHE_STORE=memory
HASH_ROUNDS=4
+FILESYSTEM_DISK=testing
```
Each test deletes the `attachments` folder first. So the test case only runs on the `testing` disk, just as it only runs on a database whose name ends in `_test`:
```diff lang="go" title="tests/test_case.go" collapse={1-15}
package tests
import (
"strings"
"github.com/goravel/framework/testing"
"taskflow/app/facades"
"taskflow/bootstrap"
)
func init() {
bootstrap.Boot()
if !strings.HasSuffix(facades.Config().GetString("database.connections.postgres.database"), "_test") {
panic("the tests drop every table, so they only run on a database whose name ends in _test: go test ./tests/... --env=.env.testing")
}
+
+ if facades.Config().GetString("filesystems.default") != "testing" {
+ panic("the tests delete uploaded files, so they only run on the testing disk: go test ./tests/... --env=.env.testing")
+ }
}
type TestCase struct {
testing.TestCase
}
```
```bash
$ go test ./tests/... --env=.env.testing
? taskflow/tests [no test files]
ok taskflow/tests/feature 1.491s
ok taskflow/tests/unit 0.756s
```
Tasks now have files. They are saved on a private disk under random names. Only the people working on the task can download them, and they are deleted with the task.
---
Previous: [13. Feature Testing](https://devchalk.com/goravel/13-feature-testing.md)
Next: [15. Events & Listeners](https://devchalk.com/goravel/15-events-listeners-notifications.md)
Series: [Goravel](https://devchalk.com/goravel.md)
# Events & Listeners
> A notification when a task is assigned.
Goravel series, part 15 of 20. By Krishan Kumar. Published 2026-10-27.
Source: https://devchalk.com/goravel/15-events-listeners-notifications
Code: https://github.com/krishankumar01/goravel-taskflow/tree/part-15
Right now, when Ana gives Ben a task, nothing tells him about it. This part sends him a notification, but the task handler doesn't write it itself: it announces the assignment as an event, `TaskAssigned`, and a separate listener writes the notification. The listener starts out inside the request and later moves to the background, so Ana's request no longer waits for it.
Start from the File Storage code:
```bash
git clone https://github.com/krishankumar01/goravel-taskflow
cd goravel-taskflow
git checkout part-14
cp .env.example .env
go run . artisan key:generate
go run . artisan jwt:secret
docker compose up -d
go run . artisan migrate:fresh --seed
docker compose exec postgres psql -U goravel -d taskflow -c "CREATE DATABASE taskflow_test"
go install github.com/air-verse/air@latest
air
```
## Why an event
The simplest way would be to write the notification in the handler, right after the task is saved, and for one notification that's fine. But an assignment usually leads to more than that, like an email or a message in a chat app. If each of those goes into the task handler, it keeps growing, and every request has to wait for all of them to finish.
An [event](https://www.goravel.dev/digging-deeper/event) splits that work up. The handler only announces what happened, that Ana assigned task 8 to Ben, and the listeners decide what to do about it. Sending an email as well would then mean adding one more listener, without touching the handler at all.
## Installing events
A new Goravel app doesn't have events yet. Install them like any other facade:
```bash
$ go run . artisan package:install Event
INFO Event depends on Log, Cache, Orm, Schema, DB, Queue, they will be installed simultaneously
SUCCESS Facade Event installed successfully
```
The queue is on that list because a listener can run on it. TaskFlow has had the queue since [Installation & Project Architecture](https://devchalk.com/goravel/01-installation-project-architecture), so only the event pieces were added: `app/facades/event.go`, and the event service provider in `bootstrap/providers.go`.
## The notifications table
This is the table [Installation & Project Architecture](https://devchalk.com/goravel/01-installation-project-architecture) planned. A notification is one row with who it is for, which task it is about and the text to show, plus `read_at`, which stays empty until the user has read it:
```go title="app/models/notification.go"
package models
import (
"github.com/goravel/framework/database/orm"
"github.com/goravel/framework/support/carbon"
)
type Notification struct {
orm.Model
UserID uint `gorm:"not null" json:"user_id"`
TaskID uint `gorm:"not null" json:"task_id"`
Message string `gorm:"size:255;not null" json:"message"`
ReadAt *carbon.DateTime `json:"read_at"`
}
```
Register the model next to the others, as in [File Storage](https://devchalk.com/goravel/14-file-storage-attachments#the-attachments-table):
```diff lang="go" title="bootstrap/app.go" collapse={1-34} collapse={41-74}
package bootstrap
import (
"context"
"strings"
"github.com/goravel/framework/auth/access"
contractsaccess "github.com/goravel/framework/contracts/auth/access"
"github.com/goravel/framework/contracts/database/schema"
contractsfoundation "github.com/goravel/framework/contracts/foundation"
"github.com/goravel/framework/contracts/http"
"github.com/goravel/framework/foundation"
"github.com/goravel/framework/http/limit"
"taskflow/app/enums"
"taskflow/app/facades"
"taskflow/app/http/authuser"
"taskflow/app/models"
"taskflow/app/policies"
"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{}, models.Attachment{}},
+ Models: []any{models.User{}, models.Task{}, models.Attachment{}, models.Notification{}},
})
facades.RateLimiter().For("login", func(ctx http.Context) http.Limit {
email := strings.ToLower(strings.TrimSpace(ctx.Request().Input("email")))
return limit.PerMinute(5).By(email).Response(func(ctx http.Context) {
ctx.Response().Json(429, map[string]any{
"error": "Too many login attempts. Try again in a minute.",
}).Abort()
})
})
facades.Gate().Before(func(ctx context.Context, ability string, arguments map[string]any) contractsaccess.Response {
if user := authuser.From(ctx); user != nil && user.Role == enums.RoleManager {
return access.NewAllowResponse()
}
return nil
})
facades.Gate().Define(policies.ViewUsers, func(ctx context.Context, arguments map[string]any) contractsaccess.Response {
return access.NewDenyResponse("Only managers can see the users.")
})
taskPolicy := policies.NewTaskPolicy()
facades.Gate().Define(policies.CreateTask, taskPolicy.Create)
facades.Gate().Define(policies.UpdateTask, taskPolicy.Update)
facades.Gate().Define(policies.DeleteTask, taskPolicy.Delete)
attachmentPolicy := policies.NewAttachmentPolicy()
facades.Gate().Define(policies.CreateAttachment, attachmentPolicy.Create)
facades.Gate().Define(policies.ViewAttachment, attachmentPolicy.View)
facades.Gate().Define(policies.DeleteAttachment, attachmentPolicy.Delete)
}).
Create()
}
```
Then generate the migration from the model:
```bash
$ go run . artisan make:migration create_notifications_table -m Notification
```
The columns come from the model, but the two foreign keys don't, so add them. The one on `task_id` uses `CascadeOnDelete`, so when a task is deleted, Postgres deletes its notifications along with it. Without that, Postgres would refuse to delete any task that has a notification:
```go title="database/migrations/20261026214627_create_notifications_table.go" collapse={1-16} collapse={31-39}
package migrations
import (
"github.com/goravel/framework/contracts/database/schema"
"taskflow/app/facades"
)
type M20261026214627CreateNotificationsTable struct{}
// Signature The unique signature for the migration.
func (r *M20261026214627CreateNotificationsTable) Signature() string {
return "20261026214627_create_notifications_table"
}
// Up Run the migrations.
func (r *M20261026214627CreateNotificationsTable) Up() error {
if !facades.Schema().HasTable("notifications") {
return facades.Schema().Create("notifications", func(table schema.Blueprint) {
table.TimestampTz("created_at").Nullable()
table.TimestampTz("updated_at").Nullable()
table.BigIncrements("id")
table.UnsignedBigInteger("user_id")
table.UnsignedBigInteger("task_id")
table.String("message", 255)
table.TimestampTz("read_at").Nullable()
table.Foreign("user_id").References("id").On("users")
table.Foreign("task_id").References("id").On("tasks").CascadeOnDelete()
})
}
return nil
}
// Down Reverse the migrations.
func (r *M20261026214627CreateNotificationsTable) Down() error {
return facades.Schema().DropIfExists("notifications")
}
```
```bash
$ go run . artisan migrate
```
## The event
Generate the event:
```bash
$ go run . artisan make:event TaskAssigned
```
A Goravel event has two parts: a struct whose name says what happened, and a list of args with the details. `TaskAssigned` keeps its struct empty, for a reason the Gotcha below explains, so the details all go in the args: the task, who got it and who gave it:
```go title="app/events/task_assigned.go" t1:event=/return (&TaskAssigned[{][}])/ t2:ids=/NewTaskAssigned[(](taskID, assignedTo, assignedBy uint)[)]/
package events
import (
"errors"
"github.com/goravel/framework/contracts/event"
)
type TaskAssigned struct {
}
func NewTaskAssigned(taskID, assignedTo, assignedBy uint) (*TaskAssigned, []event.Arg) {
return &TaskAssigned{}, []event.Arg{
{Type: "uint", Value: taskID},
{Type: "uint", Value: assignedTo},
{Type: "uint", Value: assignedBy},
}
}
func (receiver *TaskAssigned) Handle(args []event.Arg) ([]event.Arg, error) {
if len(args) != 3 {
return nil, errors.New("task assigned: needs the task id, the assignee id and the id of who assigned it")
}
for _, arg := range args {
if _, ok := arg.Value.(uint); !ok {
return nil, errors.New("task assigned: every id must be a uint")
}
}
return args, nil
}
```
- `NewTaskAssigned` builds both parts at once, so every call passes all three ids, in the same order and as `uint`s. When Ana gives task 8 to Ben, the call is `NewTaskAssigned(8, 5, 1)`: task 8, Ben (user 5) and Ana (user 1).
- `Type: "uint"` isn't used yet. It matters once the listener runs [on the queue](#running-the-listener-in-the-background), where the job is stored as JSON and the worker reads `Type` to turn `8` back into a `uint`.
- `Handle` runs before any listener, so bad args fail right away at `Dispatch`, in the handler, and not later inside a listener. `NewTaskAssigned` always gets the args right, so the check is there for a call that builds them by hand.
## The listener
Generate the listener:
```bash
$ go run . artisan make:listener NotifyAssignee
```
A listener is the code that reacts to an event. Here is `NotifyAssignee`:
```go title="app/listeners/notify_assignee.go" t2:ids=/Handle[(](args [.][.][.]any)[)] error/ t3:sig=/"(notify_assignee)"/
package listeners
import (
"errors"
"fmt"
"github.com/goravel/framework/contracts/event"
frameworkerrors "github.com/goravel/framework/errors"
"taskflow/app/facades"
"taskflow/app/models"
)
type NotifyAssignee struct {
}
func (receiver *NotifyAssignee) Signature() string {
return "notify_assignee"
}
func (receiver *NotifyAssignee) Queue(args ...any) event.Queue {
return event.Queue{
Enable: false,
Connection: "",
Queue: "",
}
}
func (receiver *NotifyAssignee) Handle(args ...any) error {
taskID, ok1 := args[0].(uint)
assignedTo, ok2 := args[1].(uint)
assignedBy, ok3 := args[2].(uint)
if !ok1 || !ok2 || !ok3 {
return errors.New("notify assignee: the ids must be uints")
}
if assignedTo == assignedBy {
return nil
}
var task models.Task
err := facades.Orm().Query().FindOrFail(&task, taskID)
if errors.Is(err, frameworkerrors.OrmRecordNotFound) {
return nil
}
if err != nil {
return err
}
var manager models.User
if err := facades.Orm().Query().FindOrFail(&manager, assignedBy); err != nil {
return err
}
return facades.Orm().Query().Create(&models.Notification{
UserID: assignedTo,
TaskID: task.ID,
Message: fmt.Sprintf("%s assigned you %q", manager.Name, task.Title),
})
}
```
- `Signature` names the listener `notify_assignee`. Once the listener runs on the queue, the worker finds it by this name.
- `Queue` returns `Enable: false` for now, so the listener runs right away, inside the request.
- `Handle` writes the notification from the args, which arrive in the order the event put them in. It skips a manager who takes a task themselves, and it checks the type of every arg and returns an error instead of crashing; the [Gotcha in the queue section](#running-the-listener-in-the-background) shows why.
## Firing the event
The event and the listener exist now, but nothing connects them yet. `WithEvents` in `bootstrap/app.go` does that, by listing each event with the listeners that react to it. One event can have several listeners, and in the request they run in the order listed here:
```diff lang="go" title="bootstrap/app.go" collapse={1-7} collapse={23-33} collapse={43-81} t1:event=/(&events.TaskAssigned[{][}]):/
package bootstrap
import (
"context"
"strings"
"github.com/goravel/framework/auth/access"
contractsaccess "github.com/goravel/framework/contracts/auth/access"
"github.com/goravel/framework/contracts/database/schema"
+ contractsevent "github.com/goravel/framework/contracts/event"
contractsfoundation "github.com/goravel/framework/contracts/foundation"
"github.com/goravel/framework/contracts/http"
"github.com/goravel/framework/foundation"
"github.com/goravel/framework/http/limit"
"taskflow/app/enums"
+ "taskflow/app/events"
"taskflow/app/facades"
"taskflow/app/http/authuser"
+ "taskflow/app/listeners"
"taskflow/app/models"
"taskflow/app/policies"
"taskflow/config"
"taskflow/routes"
)
func Boot() contractsfoundation.Application {
return foundation.Setup().
WithRules(Rules).
WithSeeders(Seeders).
WithMigrations(Migrations).
WithRouting(func() {
routes.Api()
routes.Web()
}).
+ WithEvents(func() map[contractsevent.Event][]contractsevent.Listener {
+ return map[contractsevent.Event][]contractsevent.Listener{
+ &events.TaskAssigned{}: {&listeners.NotifyAssignee{}},
+ }
+ }).
WithProviders(Providers).
WithConfig(config.Boot).
WithCallback(func() {
facades.Schema().Extend(schema.Extension{
Models: []any{models.User{}, models.Task{}, models.Attachment{}, models.Notification{}},
})
facades.RateLimiter().For("login", func(ctx http.Context) http.Limit {
email := strings.ToLower(strings.TrimSpace(ctx.Request().Input("email")))
return limit.PerMinute(5).By(email).Response(func(ctx http.Context) {
ctx.Response().Json(429, map[string]any{
"error": "Too many login attempts. Try again in a minute.",
}).Abort()
})
})
facades.Gate().Before(func(ctx context.Context, ability string, arguments map[string]any) contractsaccess.Response {
if user := authuser.From(ctx); user != nil && user.Role == enums.RoleManager {
return access.NewAllowResponse()
}
return nil
})
facades.Gate().Define(policies.ViewUsers, func(ctx context.Context, arguments map[string]any) contractsaccess.Response {
return access.NewDenyResponse("Only managers can see the users.")
})
taskPolicy := policies.NewTaskPolicy()
facades.Gate().Define(policies.CreateTask, taskPolicy.Create)
facades.Gate().Define(policies.UpdateTask, taskPolicy.Update)
facades.Gate().Define(policies.DeleteTask, taskPolicy.Delete)
attachmentPolicy := policies.NewAttachmentPolicy()
facades.Gate().Define(policies.CreateAttachment, attachmentPolicy.Create)
facades.Gate().Define(policies.ViewAttachment, attachmentPolicy.View)
facades.Gate().Define(policies.DeleteAttachment, attachmentPolicy.Delete)
}).
Create()
}
```
> **Gotcha** Goravel finds an event's listeners by its pointer, so the `&TaskAssigned{}` the handler fires has to match the one in `WithEvents`. They're two separate values, and they only match because Go usually puts every empty struct at the same address. Add a field, like `TaskID uint`, and each one gets its own address, so Goravel finds no listeners:
>
> ```text
> event &{0} doesn't bind listeners
> ```
>
> That's why the struct stays empty and the ids go in the args.
Now the task handlers fire the event. A task gets an assignee in two places: when Ana creates it, in `Store`, and when she changes it, in `Update`. Both fire `TaskAssigned` only when someone new is assigned, so renaming a task sends nothing:
```diff lang="go" title="app/http/controllers/task_controller.go" collapse={11-70} collapse={84-191} collapse={200-221} collapse={235-269}
package controllers
import (
"errors"
"fmt"
"taskflow/app/enums"
+ "taskflow/app/events"
"taskflow/app/facades"
"taskflow/app/http/authuser"
"taskflow/app/http/requests"
"taskflow/app/models"
"taskflow/app/policies"
"github.com/goravel/framework/contracts/http"
frameworkerrors "github.com/goravel/framework/errors"
"github.com/goravel/framework/validation"
)
type TaskController struct {
// Dependent services
}
func NewTaskController() *TaskController {
return &TaskController{
// Inject services
}
}
func (r *TaskController) Store(ctx http.Context) http.Response {
if response := facades.Gate().WithContext(ctx).Inspect(policies.CreateTask, nil); !response.Allowed() {
return ctx.Response().Json(403, map[string]any{
"error": response.Message(),
})
}
var request requests.StoreTaskRequest
validationErrors, err := ctx.Request().ValidateRequest(&request)
if err != nil {
facades.Log().Error(err)
return ctx.Response().Json(500, map[string]any{
"error": "Could not save the task.",
})
}
if validationErrors != nil {
return ctx.Response().Json(422, map[string]any{
"errors": validationErrors.All(),
})
}
task := models.Task{
CreatedBy: authuser.From(ctx).ID,
AssignedTo: request.AssignedTo,
Title: request.Title,
Description: request.Description,
Status: enums.StatusPending,
DueAt: request.DueAt,
}
if err := facades.Orm().WithContext(ctx).Query().Create(&task); err != nil {
facades.Log().Error(err)
return ctx.Response().Json(500, map[string]any{
"error": "Could not save the task.",
})
}
var saved models.Task
err = facades.Orm().WithContext(ctx).Query().With("Creator").With("Assignee").Find(&saved, task.ID)
if err != nil {
facades.Log().Error(err)
return ctx.Response().Json(500, map[string]any{
"error": "Could not load the task.",
})
}
+ if saved.AssignedTo != nil {
+ err := facades.Event().Job(events.NewTaskAssigned(saved.ID, *saved.AssignedTo, authuser.From(ctx).ID)).Dispatch()
+ if err != nil {
+ facades.Log().Error(err)
+ }
+ }
+
return ctx.Response().Json(201, saved)
}
func (r *TaskController) Show(ctx http.Context) http.Response {
var task models.Task
err := facades.Orm().WithContext(ctx).Query().
With("Creator").
With("Assignee").
With("Attachments").
FindOrFail(&task, ctx.Request().RouteInt("id"))
if errors.Is(err, frameworkerrors.OrmRecordNotFound) {
return ctx.Response().Json(404, map[string]any{
"error": "The task does not exist.",
})
}
if err != nil {
facades.Log().Error(err)
return ctx.Response().Json(500, map[string]any{
"error": "Could not load the task.",
})
}
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",
"per_page": "bail|filled|integer|min:1|max:100",
})
if err != nil {
facades.Log().Error(err)
return ctx.Response().Json(500, map[string]any{
"error": "Could not list the tasks.",
})
}
if validator.Fails() {
return ctx.Response().Json(422, map[string]any{
"errors": validator.Errors().All(),
})
}
page := ctx.Request().QueryInt("page", 1)
perPage := ctx.Request().QueryInt("per_page", 15)
query := facades.Orm().WithContext(ctx).Query()
if status := ctx.Request().Query("status"); status != "" {
query = query.Where("status", status)
}
var tasks []models.Task
var total int64
err = query.With("Creator").With("Assignee").OrderByDesc("id").Paginate(page, perPage, &tasks, &total)
if err != nil {
facades.Log().Error(err)
return ctx.Response().Json(500, map[string]any{
"error": "Could not list the tasks.",
})
}
return ctx.Response().Json(200, map[string]any{
"data": tasks,
"meta": map[string]any{
"page": page,
"per_page": perPage,
"total": total,
},
})
}
func (r *TaskController) Update(ctx http.Context) http.Response {
var task models.Task
err := facades.Orm().WithContext(ctx).Query().FindOrFail(&task, ctx.Request().RouteInt("id"))
if errors.Is(err, frameworkerrors.OrmRecordNotFound) {
return ctx.Response().Json(404, map[string]any{
"error": "The task does not exist.",
})
}
if err != nil {
facades.Log().Error(err)
return ctx.Response().Json(500, map[string]any{
"error": "Could not load the task.",
})
}
var request requests.UpdateTaskRequest
if err := request.Authorize(ctx); err != nil {
facades.Log().Error(err)
return ctx.Response().Json(500, map[string]any{
"error": "Could not save the task.",
})
}
validator, err := ctx.Request().Validate(
request.Rules(ctx),
validation.Filters(request.Filters(ctx)),
validation.Attributes(request.Attributes(ctx)),
)
if err != nil {
facades.Log().Error(err)
return ctx.Response().Json(500, map[string]any{
"error": "Could not save the task.",
})
}
if validator.Fails() {
return ctx.Response().Json(422, map[string]any{
"errors": validator.Errors().All(),
})
}
changes := validator.Validated()
+ var assignedBefore uint
+ if task.AssignedTo != nil {
+ assignedBefore = *task.AssignedTo
+ }
response := facades.Gate().WithContext(ctx).Inspect(policies.UpdateTask, map[string]any{
"task": &task,
"changes": changes,
})
if !response.Allowed() {
return ctx.Response().Json(403, map[string]any{
"error": response.Message(),
})
}
if len(changes) > 0 {
if _, err := facades.Orm().WithContext(ctx).Query().Model(&task).Update(changes); err != nil {
facades.Log().Error(err)
return ctx.Response().Json(500, map[string]any{
"error": "Could not save the task.",
})
}
}
var saved models.Task
err = facades.Orm().WithContext(ctx).Query().With("Creator").With("Assignee").Find(&saved, task.ID)
if err != nil {
facades.Log().Error(err)
return ctx.Response().Json(500, map[string]any{
"error": "Could not load the task.",
})
}
+ if saved.AssignedTo != nil && *saved.AssignedTo != assignedBefore {
+ err := facades.Event().Job(events.NewTaskAssigned(saved.ID, *saved.AssignedTo, authuser.From(ctx).ID)).Dispatch()
+ if err != nil {
+ facades.Log().Error(err)
+ }
+ }
+
return ctx.Response().Json(200, saved)
}
func (r *TaskController) Destroy(ctx http.Context) http.Response {
var task models.Task
err := facades.Orm().WithContext(ctx).Query().FindOrFail(&task, ctx.Request().RouteInt("id"))
if errors.Is(err, frameworkerrors.OrmRecordNotFound) {
return ctx.Response().Json(404, map[string]any{
"error": "The task does not exist.",
})
}
if err != nil {
facades.Log().Error(err)
return ctx.Response().Json(500, map[string]any{
"error": "Could not load the task.",
})
}
if response := facades.Gate().WithContext(ctx).Inspect(policies.DeleteTask, nil); !response.Allowed() {
return ctx.Response().Json(403, map[string]any{
"error": response.Message(),
})
}
if _, err := facades.Orm().WithContext(ctx).Query().Delete(&task); err != nil {
facades.Log().Error(err)
return ctx.Response().Json(500, map[string]any{
"error": "Could not delete the task.",
})
}
if err := facades.Storage().DeleteDirectory(fmt.Sprintf("attachments/%d", task.ID)); err != nil {
facades.Log().Error(err)
}
return ctx.Response().NoContent()
}
```
- `Job(...)` gets the event ready and `Dispatch()` sends it: first the event's `Handle` runs, then each listener. `NewTaskAssigned` returns the event and its args, which is exactly what `Job` takes.
- `Update(changes)` writes the new values into `task` itself, so the old assignee is copied into `assignedBefore` before the save. Without that copy, the handler couldn't tell that the assignee changed.
- If `Dispatch` returns an error, the task is already saved, so the handler only logs the error and still answers `201` or `200`.
## Reading notifications
Each user reads their own notifications at `GET /notifications`, newest first. They pile up, one for every assignment, so the list comes in pages, like the task list in [Task CRUD](https://devchalk.com/goravel/11-task-crud). `POST /notifications/{id}/read` marks one as read:
```go title="app/http/controllers/notification_controller.go" collapse={1-24} collapse={29-44} collapse={51-66}
package controllers
import (
"errors"
"github.com/goravel/framework/contracts/http"
frameworkerrors "github.com/goravel/framework/errors"
"github.com/goravel/framework/support/carbon"
"taskflow/app/facades"
"taskflow/app/http/authuser"
"taskflow/app/models"
)
type NotificationController struct {
// Dependent services
}
func NewNotificationController() *NotificationController {
return &NotificationController{
// Inject services
}
}
func (r *NotificationController) Index(ctx http.Context) http.Response {
validator, err := ctx.Request().Validate(map[string]any{
"page": "bail|filled|integer|min:1",
"per_page": "bail|filled|integer|min:1|max:100",
})
if err != nil {
facades.Log().Error(err)
return ctx.Response().Json(500, map[string]any{
"error": "Could not list the notifications.",
})
}
if validator.Fails() {
return ctx.Response().Json(422, map[string]any{
"errors": validator.Errors().All(),
})
}
page := ctx.Request().QueryInt("page", 1)
perPage := ctx.Request().QueryInt("per_page", 15)
var notifications []models.Notification
var total int64
err = facades.Orm().WithContext(ctx).Query().
Where("user_id", authuser.From(ctx).ID).
OrderByDesc("id").
Paginate(page, perPage, ¬ifications, &total)
if err != nil {
facades.Log().Error(err)
return ctx.Response().Json(500, map[string]any{
"error": "Could not list the notifications.",
})
}
return ctx.Response().Json(200, map[string]any{
"data": notifications,
"meta": map[string]any{
"page": page,
"per_page": perPage,
"total": total,
},
})
}
func (r *NotificationController) Read(ctx http.Context) http.Response {
var notification models.Notification
err := facades.Orm().WithContext(ctx).Query().
Where("user_id", authuser.From(ctx).ID).
FindOrFail(¬ification, ctx.Request().RouteInt("id"))
if errors.Is(err, frameworkerrors.OrmRecordNotFound) {
return ctx.Response().Json(404, map[string]any{
"error": "The notification does not exist.",
})
}
if err != nil {
facades.Log().Error(err)
return ctx.Response().Json(500, map[string]any{
"error": "Could not load the notification.",
})
}
if notification.ReadAt == nil {
notification.ReadAt = carbon.NewDateTime(carbon.Now().StartOfSecond())
if err := facades.Orm().WithContext(ctx).Query().Save(¬ification); err != nil {
facades.Log().Error(err)
return ctx.Response().Json(500, map[string]any{
"error": "Could not save the notification.",
})
}
}
return ctx.Response().Json(200, notification)
}
```
- Both handlers look only among the user's own notifications, so when `Read` gets another user's id, it answers the same `404` as for an id that doesn't exist.
- `Read` sets `read_at` only once, so it keeps when the user first read it. `StartOfSecond` drops the fraction of a second: the column keeps whole seconds and Postgres rounds, so without it the answer could show a different second from the one saved.
```diff lang="go" title="routes/api.go" collapse={1-14} collapse={21-35}
package routes
import (
"taskflow/app/facades"
"taskflow/app/http/controllers"
"taskflow/app/http/middleware"
"github.com/goravel/framework/contracts/route"
httpmiddleware "github.com/goravel/framework/http/middleware"
)
func Api() {
authController := controllers.NewAuthController()
taskController := controllers.NewTaskController()
userController := controllers.NewUserController()
attachmentController := controllers.NewAttachmentController()
+ notificationController := controllers.NewNotificationController()
facades.Route().Prefix("api/v1").Group(func(router route.Router) {
router.Post("register", authController.Register)
router.Middleware(httpmiddleware.Throttle("login")).Post("login", authController.Login)
router.Post("refresh", authController.Refresh)
router.Middleware(&middleware.Auth{}).Group(func(router route.Router) {
router.Get("me", authController.Me)
router.Post("logout", authController.Logout)
router.Post("tasks", taskController.Store).Name("tasks.create")
router.Get("tasks/{id}", taskController.Show)
router.Get("tasks", taskController.Index)
router.Patch("tasks/{id}", taskController.Update)
router.Delete("tasks/{id}", taskController.Destroy)
router.Post("tasks/{id}/attachments", attachmentController.Store)
router.Get("tasks/{id}/attachments/{attachment}", attachmentController.Show)
router.Delete("tasks/{id}/attachments/{attachment}", attachmentController.Destroy)
+ router.Get("notifications", notificationController.Index)
+ router.Post("notifications/{id}/read", notificationController.Read)
+
router.Get("users", userController.Index)
router.Get("users/{id}", userController.Show)
})
})
}
```
## See it work
Set up as in [Gates & Policies](https://devchalk.com/goravel/12-gates-policies#the-rules), with one more member:
1. Reset the database with `migrate:fresh --seed`. The seeder adds Ana, the manager, and three members.
2. Register Ben and Cat. They get ids 5 and 6.
3. Log in as Ana, Ben and Cat, and save each token in a variable: `ANA`, `BEN` and `CAT`.
Ana creates a task for Ben, and his notification is there as soon as the request returns. Cat can't mark it as read, but Ben can:
```bash
$ curl -X POST http://localhost:3000/api/v1/tasks \
-H "Authorization: Bearer $ANA" \
-H "Content-Type: application/json" \
-d '{"title":"Fix the login page","description":"The error message overlaps the button.","assigned_to":5}'
{…,"id":8,…,"assigned_to":5,…,"title":"Fix the login page",…}
$ curl http://localhost:3000/api/v1/notifications -H "Authorization: Bearer $BEN"
{"data":[{…,"id":1,"user_id":5,"task_id":8,"message":"Ana assigned you \"Fix the login page\"","read_at":null}],"meta":{"page":1,"per_page":15,"total":1}}
$ curl -X POST http://localhost:3000/api/v1/notifications/1/read -H "Authorization: Bearer $CAT"
{"error":"The notification does not exist."}
$ curl -X POST http://localhost:3000/api/v1/notifications/1/read -H "Authorization: Bearer $BEN"
{…,"id":1,"user_id":5,"task_id":8,"message":"Ana assigned you \"Fix the login page\"","read_at":"2026-10-26 21:50:12"}
```
Then Ana renames the task, gives it to Cat, and finally takes it herself. Of those three changes, only giving it to Cat sends a notification:
```bash
$ curl -s -o /dev/null -w "%{http_code}\n" -X PATCH http://localhost:3000/api/v1/tasks/8 -H "Authorization: Bearer $ANA" -H "Content-Type: application/json" -d '{"title":"Fix the login form"}'
200
$ curl -s -o /dev/null -w "%{http_code}\n" -X PATCH http://localhost:3000/api/v1/tasks/8 -H "Authorization: Bearer $ANA" -H "Content-Type: application/json" -d '{"assigned_to":6}'
200
$ curl -s -o /dev/null -w "%{http_code}\n" -X PATCH http://localhost:3000/api/v1/tasks/8 -H "Authorization: Bearer $ANA" -H "Content-Type: application/json" -d '{"assigned_to":1}'
200
$ curl http://localhost:3000/api/v1/notifications -H "Authorization: Bearer $CAT"
{"data":[{…,"id":2,"user_id":6,"task_id":8,"message":"Ana assigned you \"Fix the login form\"","read_at":null}],"meta":{"page":1,"per_page":15,"total":1}}
$ curl http://localhost:3000/api/v1/notifications -H "Authorization: Bearer $ANA"
{"data":[],"meta":{"page":1,"per_page":15,"total":0}}
```
Ben still has only his first notification, now read.
When a listener fails:
Listeners in the request run one after another, and as soon as one returns an error, the ones after it are skipped. A listener that always fails makes this easy to watch. Make one with `make:listener AlwaysFails`, and have its `Handle` return an error:
```go
func (receiver *AlwaysFails) Handle(args ...any) error {
return errors.New("this listener always fails")
}
```
Then put it in front of `NotifyAssignee` in `WithEvents`:
```go
&events.TaskAssigned{}: {&listeners.AlwaysFails{}, &listeners.NotifyAssignee{}},
```
Ana gives Ben one more task. It is saved and she gets her `201`, because the handler only logs the error, but `NotifyAssignee` never runs, so Ben's list still has only his first notification:
```bash
$ curl -X POST http://localhost:3000/api/v1/tasks \
-H "Authorization: Bearer $ANA" \
-H "Content-Type: application/json" \
-d '{"title":"Update the README","description":"Add the new endpoints.","assigned_to":5}'
{…,"id":9,…,"assigned_to":5,…,"title":"Update the README",…}
$ curl http://localhost:3000/api/v1/notifications -H "Authorization: Bearer $BEN"
{"data":[{…,"id":1,"user_id":5,"task_id":8,"message":"Ana assigned you \"Fix the login page\"","read_at":"2026-10-26 21:50:12"}],"meta":{"page":1,"per_page":15,"total":1}}
$ grep local.error storage/logs/goravel-*.log
[2026-10-26 21:50:19.486] local.error: this listener always fails
```
Goravel's docs call this "[stopping the propagation](https://www.goravel.dev/digging-deeper/event#stopping-the-propagation-of-an-event)". On the queue, each listener is a job of its own, so if `AlwaysFails` were queued too, it would fail in the worker and `NotifyAssignee` would still run. Take `AlwaysFails` out of `WithEvents` again before going on.
## Running the listener in the background
As long as the listener runs inside the request, Ana's request waits for it. Writing one row is quick enough that she won't notice, but an email can take seconds, and if the mail server is down, she'd be waiting on that too. A queue fixes this: the handler leaves a job on a list and answers right away, and a worker picks the job up a moment later and runs the listener.
TaskFlow already has both halves. The `config/queue.go` that came with the project sets `default` to `redis`. Of its three connections, `sync` runs a job straight away, inside the call, and `database` and `redis` keep jobs in Postgres or Redis:
```go title="config/queue.go" collapse={1-8} collapse={37-47}
package config
import (
"github.com/goravel/framework/contracts/queue"
redisfacades "github.com/goravel/redis/facades"
"taskflow/app/facades"
)
func init() {
config := facades.Config()
config.Add("queue", map[string]any{
"default": "redis",
// Queue Connections
//
// Here you may configure the connection information for each server that is used by your application.
// Drivers: "sync", "database", "custom"
"connections": map[string]any{
"sync": map[string]any{
"driver": "sync",
},
"database": map[string]any{
"driver": "database",
"connection": config.Env("DB_CONNECTION"),
"queue": "default",
"concurrent": 1,
},
"redis": map[string]any{
"driver": "custom",
"connection": "default",
"queue": "default",
"via": func() (queue.Driver, error) {
return redisfacades.Queue("redis") // The `redis` value is the key of `connections`
},
},
},
// Failed Queue Jobs
//
// These options configure the behavior of failed queue job logging so you
// can control how and where failed jobs are stored.
"failed": map[string]any{
"database": config.Env("DB_CONNECTION"),
"table": "failed_jobs",
},
})
}
```
Whenever the default isn't `sync`, `app.Start()` also runs a worker next to the HTTP server, which is the line `air` prints when it starts:
```text
INFO Processing jobs from [redis] connection and [default] queue
```
A listener's `Queue` method decides whether it uses that queue:
- `Enable` puts the listener on the queue when it is `true`.
- `Connection` picks one of the `connections` above. Empty means the `default` one.
- `Queue` picks a queue on that connection, for example to keep slow jobs apart from quick ones. Empty means the default connection's `queue`, which is `default`.
So to put `NotifyAssignee` on the queue, only `Enable` changes:
```diff lang="go" title="app/listeners/notify_assignee.go" collapse={1-20} collapse={28-60}
package listeners
import (
"errors"
"fmt"
"github.com/goravel/framework/contracts/event"
frameworkerrors "github.com/goravel/framework/errors"
"taskflow/app/facades"
"taskflow/app/models"
)
type NotifyAssignee struct {
}
func (receiver *NotifyAssignee) Signature() string {
return "notify_assignee"
}
func (receiver *NotifyAssignee) Queue(args ...any) event.Queue {
return event.Queue{
- Enable: false,
+ Enable: true,
Connection: "",
Queue: "",
}
}
func (receiver *NotifyAssignee) Handle(args ...any) error {
taskID, ok1 := args[0].(uint)
assignedTo, ok2 := args[1].(uint)
assignedBy, ok3 := args[2].(uint)
if !ok1 || !ok2 || !ok3 {
return errors.New("notify assignee: the ids must be uints")
}
if assignedTo == assignedBy {
return nil
}
var task models.Task
err := facades.Orm().Query().FindOrFail(&task, taskID)
if errors.Is(err, frameworkerrors.OrmRecordNotFound) {
return nil
}
if err != nil {
return err
}
var manager models.User
if err := facades.Orm().Query().FindOrFail(&manager, assignedBy); err != nil {
return err
}
return facades.Orm().Query().Create(&models.Notification{
UserID: assignedTo,
TaskID: task.ID,
Message: fmt.Sprintf("%s assigned you %q", manager.Name, task.Title),
})
}
```
Ana gives Ben a third task, and Ben checks his list straight away and again a second later. Paste the four commands together, because the worker looks for new jobs about once a second, and typed by hand, even the first check would come too late:
```bash
$ curl -X POST http://localhost:3000/api/v1/tasks \
-H "Authorization: Bearer $ANA" \
-H "Content-Type: application/json" \
-d '{"title":"Write the API docs","description":"Start with the auth routes.","assigned_to":5}'
{…,"assigned_to":5,…,"title":"Write the API docs",…}
$ curl http://localhost:3000/api/v1/notifications -H "Authorization: Bearer $BEN"
{"data":[{…,"id":1,…,"message":"Ana assigned you \"Fix the login page\"",…}],"meta":{"page":1,"per_page":15,"total":1}}
$ sleep 1
$ curl http://localhost:3000/api/v1/notifications -H "Authorization: Bearer $BEN"
{"data":[{…,"id":3,…,"message":"Ana assigned you \"Write the API docs\"","read_at":null},{…,"id":1,…}],"meta":{"page":1,"per_page":15,"total":2}}
```
Ana's request came back before the listener ran, and the worker prints the job it ran a moment later, under the listener's signature:
```text t3:sig=/(notify_assignee) [.]+ RUNNING/
2026-10-26 21:50:26.42 notify_assignee ............................. RUNNING
2026-10-26 21:50:26.428 notify_assignee ....................... 8.159ms DONE
```
Three things change once a listener is on the queue:
- **Errors no longer reach the handler.** `Dispatch` only fails if the job can't be put on the queue, so if the listener fails later, only the worker knows.
- **The args must be plain values.** The job is saved in Redis as JSON, and the worker rebuilds each value from its `Type`. That works for plain values like numbers and strings, which is why the event passes ids and not a `Task` or a `User`.
- **The world may change before the job runs.** By then the task may have been renamed, given to someone else or deleted. That's why the listener takes the assignee from the args, which say who was assigned at that moment, and why it stops quietly, without an error, when the task is gone.
> **Gotcha** A panic in a queued listener stops the whole app. The worker runs inside the same process as the HTTP server and doesn't recover from a panic, so an unchecked `args[0].(int)` on a `uint` brings both down:
>
> ```text
> panic: interface conversion: interface {} is uint, not int
> ```
>
> The process exits, so the API is down until it's started again. That's why the listener checks every type and returns an error instead.
Why not a model observer:
Goravel can also run code whenever a model is saved, with an [observer](https://www.goravel.dev/orm/getting-started#observers). A `TaskObserver`'s `Created` and `Updated` methods could write the notification when `assigned_to` is set or changes. But an observer fires on every save, wherever it comes from, so the seeders would send notifications too, and it runs inside the save, not on the queue. Only the task handler knows "Ana just gave this task to Ben", so TaskFlow fires the event there.
Here is the whole trip, first with the listener in the request, then on the queue:
*Two assignments: the first with the listener in the request, the second on the queue.*
In the request: the client sends the new task, the API saves it in Postgres, fires TaskAssigned, NotifyAssignee inserts the notification, and only then does the API answer 201. On the queue: the API saves the task, puts a notify_assignee job on Redis and answers 201 at once. The worker takes the job from Redis and NotifyAssignee inserts the notification a moment later.
> **Note** Goravel 1.19 is set to add its own notifications. Until then, an event and a listener do the job.
## Tests
The tests for this part are in [`tests/feature/notification_test.go`](https://github.com/krishankumar01/goravel-taskflow/blob/part-15/tests/feature/notification_test.go) in TaskFlow. They check who gets a notification after each kind of change, that users only see and mark their own, that deleting a task removes its notifications, and that the event turns down bad args.
The tests don't start the app, so no worker runs, and a queued listener would never run at all. The `sync` connection runs it straight away instead, so the queue's `default` now comes from an env variable, and the tests set it to `sync`:
```diff lang="go" title="config/queue.go" collapse={1-8} collapse={15-48}
package config
import (
"github.com/goravel/framework/contracts/queue"
redisfacades "github.com/goravel/redis/facades"
"taskflow/app/facades"
)
func init() {
config := facades.Config()
config.Add("queue", map[string]any{
- "default": "redis",
+ "default": config.Env("QUEUE_CONNECTION", "redis"),
// Queue Connections
//
// Here you may configure the connection information for each server that is used by your application.
// Drivers: "sync", "database", "custom"
"connections": map[string]any{
"sync": map[string]any{
"driver": "sync",
},
"database": map[string]any{
"driver": "database",
"connection": config.Env("DB_CONNECTION"),
"queue": "default",
"concurrent": 1,
},
"redis": map[string]any{
"driver": "custom",
"connection": "default",
"queue": "default",
"via": func() (queue.Driver, error) {
return redisfacades.Queue("redis") // The `redis` value is the key of `connections`
},
},
},
// Failed Queue Jobs
//
// These options configure the behavior of failed queue job logging so you
// can control how and where failed jobs are stored.
"failed": map[string]any{
"database": config.Env("DB_CONNECTION"),
"table": "failed_jobs",
},
})
}
```
`.env.example` gets the line with `redis`, and an `.env` without it falls back to `redis` too:
```diff lang="bash" title=".env.example" collapse={1-23}
APP_NAME=Goravel
APP_ENV=local
APP_KEY=
APP_DEBUG=true
LOG_CHANNEL=stack
LOG_LEVEL=debug
SESSION_DRIVER=file
SESSION_LIFETIME=120
APP_URL=http://localhost
APP_HOST=127.0.0.1
APP_PORT=3000
JWT_SECRET=
DB_CONNECTION=postgres
DB_HOST=127.0.0.1
DB_PORT=5433
DB_DATABASE=taskflow
DB_USERNAME=goravel
DB_PASSWORD=secret
FILESYSTEM_DISK=local
+QUEUE_CONNECTION=redis
REDIS_HOST=127.0.0.1
REDIS_PASSWORD=
REDIS_PORT=6380
```
and the tests get `sync`:
```diff lang="bash" title=".env.testing" collapse={1-29}
APP_NAME=Goravel
APP_ENV=testing
APP_KEY=2Nr9bpCIal8Dj0qYE7IviHcYcwzmVqoh
APP_DEBUG=true
LOG_CHANNEL=stack
LOG_LEVEL=debug
SESSION_DRIVER=file
SESSION_LIFETIME=120
APP_URL=http://localhost
APP_HOST=127.0.0.1
APP_PORT=3000
JWT_SECRET=xRQKZ4WHIu8bIHANdQVnskdTsa47cya8
DB_CONNECTION=postgres
DB_HOST=127.0.0.1
DB_PORT=5433
DB_DATABASE=taskflow_test
DB_USERNAME=goravel
DB_PASSWORD=secret
REDIS_HOST=127.0.0.1
REDIS_PASSWORD=
REDIS_PORT=6380
CACHE_STORE=memory
HASH_ROUNDS=4
FILESYSTEM_DISK=testing
+QUEUE_CONNECTION=sync
```
If the tests ran on `redis`, their jobs would wait in the shared Redis, and a running server could take them and write into the wrong database, so the test case refuses any queue but `sync`, the same way it refuses the wrong disk:
```diff lang="go" title="tests/test_case.go" collapse={1-19}
package tests
import (
"strings"
"github.com/goravel/framework/testing"
"taskflow/app/facades"
"taskflow/bootstrap"
)
func init() {
bootstrap.Boot()
if !strings.HasSuffix(facades.Config().GetString("database.connections.postgres.database"), "_test") {
panic("the tests drop every table, so they only run on a database whose name ends in _test: go test ./tests/... --env=.env.testing")
}
if facades.Config().GetString("filesystems.default") != "testing" {
panic("the tests delete uploaded files, so they only run on the testing disk: go test ./tests/... --env=.env.testing")
}
+
+ if facades.Config().GetString("queue.default") != "sync" {
+ panic("the tests check what queued listeners wrote, so they only run on the sync queue: go test ./tests/... --env=.env.testing")
+ }
}
type TestCase struct {
testing.TestCase
}
```
```bash
$ go test ./tests/... --env=.env.testing
? taskflow/tests [no test files]
ok taskflow/tests/feature 1.299s
ok taskflow/tests/unit 0.401s
```
Assigning a task now sends a notification, written by a listener that runs on the queue, while the task handler only says what happened.
---
Previous: [14. File Storage](https://devchalk.com/goravel/14-file-storage-attachments.md)
Next: [16. Queues & Jobs](https://devchalk.com/goravel/16-queues-jobs-task-export.md)
Series: [Goravel](https://devchalk.com/goravel.md)
# Queues & Jobs
> A CSV export that runs in the background.
Goravel series, part 16 of 20. By Krishan Kumar. Published 2026-10-28.
Source: https://devchalk.com/goravel/16-queues-jobs-task-export
Code: https://github.com/krishankumar01/goravel-taskflow/tree/part-16
This part adds a CSV export of all tasks. A big export takes a while, so the request doesn't build it. It only queues a job and returns. The job builds the file in the background, and the client downloads it when it's done.
Start from the Events & Listeners code:
```bash
git clone https://github.com/krishankumar01/goravel-taskflow
cd goravel-taskflow
git checkout part-15
cp .env.example .env
go run . artisan key:generate
go run . artisan jwt:secret
docker compose up -d
go run . artisan migrate:fresh --seed
docker compose exec postgres psql -U goravel -d taskflow -c "CREATE DATABASE taskflow_test"
go install github.com/air-verse/air@latest
air
```
## A job, not an event
An event announces what happened, and whoever listens decides what to do about it. That fit [Events & Listeners](https://devchalk.com/goravel/15-events-listeners-notifications), where the notification was one reaction to assigning a task, and more could be added later without touching the handler. For an export, the handler already knows the one thing that has to happen, and the export isn't done until the file is built. So it names that work directly by sending a [job](https://www.goravel.dev/digging-deeper/queues). The job runs on the same queue and worker as the [queued listener](https://devchalk.com/goravel/15-events-listeners-notifications#running-the-listener-in-the-background).
## The exports table
Each export is a row with its owner, its status and the file's path. The path stays out of the JSON:
```go title="app/enums/export_status.go"
package enums
type ExportStatus string
const (
ExportPending ExportStatus = "pending"
ExportDone ExportStatus = "done"
)
```
```go title="app/models/export.go"
package models
import (
"github.com/goravel/framework/database/orm"
"taskflow/app/enums"
)
type Export struct {
orm.Model
UserID uint `gorm:"not null" json:"user_id"`
Status enums.ExportStatus `gorm:"type:enum('pending','done');default:'pending';not null" json:"status"`
Path string `gorm:"size:255" json:"-"`
}
```
`make:migration` builds the migration from a model, but it only finds models listed in `Models` in `bootstrap/app.go`, so `Export` goes there first:
```diff lang="go" title="bootstrap/app.go" collapse={1-42} collapse={49-82}
package bootstrap
import (
"context"
"strings"
"github.com/goravel/framework/auth/access"
contractsaccess "github.com/goravel/framework/contracts/auth/access"
"github.com/goravel/framework/contracts/database/schema"
contractsevent "github.com/goravel/framework/contracts/event"
contractsfoundation "github.com/goravel/framework/contracts/foundation"
"github.com/goravel/framework/contracts/http"
"github.com/goravel/framework/foundation"
"github.com/goravel/framework/http/limit"
"taskflow/app/enums"
"taskflow/app/events"
"taskflow/app/facades"
"taskflow/app/http/authuser"
"taskflow/app/listeners"
"taskflow/app/models"
"taskflow/app/policies"
"taskflow/config"
"taskflow/routes"
)
func Boot() contractsfoundation.Application {
return foundation.Setup().
WithRules(Rules).
WithSeeders(Seeders).
WithMigrations(Migrations).
WithRouting(func() {
routes.Api()
routes.Web()
}).
WithEvents(func() map[contractsevent.Event][]contractsevent.Listener {
return map[contractsevent.Event][]contractsevent.Listener{
&events.TaskAssigned{}: {&listeners.NotifyAssignee{}},
}
}).
WithProviders(Providers).
WithConfig(config.Boot).
WithCallback(func() {
facades.Schema().Extend(schema.Extension{
- Models: []any{models.User{}, models.Task{}, models.Attachment{}, models.Notification{}},
+ Models: []any{models.User{}, models.Task{}, models.Attachment{}, models.Notification{}, models.Export{}},
})
facades.RateLimiter().For("login", func(ctx http.Context) http.Limit {
email := strings.ToLower(strings.TrimSpace(ctx.Request().Input("email")))
return limit.PerMinute(5).By(email).Response(func(ctx http.Context) {
ctx.Response().Json(429, map[string]any{
"error": "Too many login attempts. Try again in a minute.",
}).Abort()
})
})
facades.Gate().Before(func(ctx context.Context, ability string, arguments map[string]any) contractsaccess.Response {
if user := authuser.From(ctx); user != nil && user.Role == enums.RoleManager {
return access.NewAllowResponse()
}
return nil
})
facades.Gate().Define(policies.ViewUsers, func(ctx context.Context, arguments map[string]any) contractsaccess.Response {
return access.NewDenyResponse("Only managers can see the users.")
})
taskPolicy := policies.NewTaskPolicy()
facades.Gate().Define(policies.CreateTask, taskPolicy.Create)
facades.Gate().Define(policies.UpdateTask, taskPolicy.Update)
facades.Gate().Define(policies.DeleteTask, taskPolicy.Delete)
attachmentPolicy := policies.NewAttachmentPolicy()
facades.Gate().Define(policies.CreateAttachment, attachmentPolicy.Create)
facades.Gate().Define(policies.ViewAttachment, attachmentPolicy.View)
facades.Gate().Define(policies.DeleteAttachment, attachmentPolicy.Delete)
}).
Create()
}
```
```bash
$ go run . artisan make:migration create_exports_table -m Export
```
`make:migration` builds the columns from the model, and one more line adds the foreign key to `users`:
```go title="database/migrations/20261026231209_create_exports_table.go" collapse={1-16} collapse={30-38}
package migrations
import (
"github.com/goravel/framework/contracts/database/schema"
"taskflow/app/facades"
)
type M20261026231209CreateExportsTable struct{}
// Signature The unique signature for the migration.
func (r *M20261026231209CreateExportsTable) Signature() string {
return "20261026231209_create_exports_table"
}
// Up Run the migrations.
func (r *M20261026231209CreateExportsTable) Up() error {
if !facades.Schema().HasTable("exports") {
return facades.Schema().Create("exports", func(table schema.Blueprint) {
table.TimestampTz("created_at").Nullable()
table.TimestampTz("updated_at").Nullable()
table.BigIncrements("id")
table.UnsignedBigInteger("user_id")
table.Enum("status", []any{"pending", "done"}).Default("pending")
table.String("path", 255)
table.Foreign("user_id").References("id").On("users")
})
}
return nil
}
// Down Reverse the migrations.
func (r *M20261026231209CreateExportsTable) Down() error {
return facades.Schema().DropIfExists("exports")
}
```
```bash
$ go run . artisan migrate
```
## The job
`make:job` creates the job and registers it:
```bash
$ go run . artisan make:job ExportTasks
SUCCESS Job created successfully
SUCCESS Job registered successfully
```
`WithEvents` registers each listener with its event. A job has no event, so `make:job` registers it in a new `bootstrap/jobs.go`:
```go title="bootstrap/jobs.go"
package bootstrap
import (
"github.com/goravel/framework/contracts/queue"
"taskflow/app/jobs"
)
func Jobs() []queue.Job {
return []queue.Job{
&jobs.ExportTasks{},
}
}
```
It also adds `WithJobs` to `bootstrap/app.go`. Without it, the worker can't find the job:
```diff lang="go" title="bootstrap/app.go" collapse={1-26} collapse={33-82}
package bootstrap
import (
"context"
"strings"
"github.com/goravel/framework/auth/access"
contractsaccess "github.com/goravel/framework/contracts/auth/access"
"github.com/goravel/framework/contracts/database/schema"
contractsevent "github.com/goravel/framework/contracts/event"
contractsfoundation "github.com/goravel/framework/contracts/foundation"
"github.com/goravel/framework/contracts/http"
"github.com/goravel/framework/foundation"
"github.com/goravel/framework/http/limit"
"taskflow/app/enums"
"taskflow/app/events"
"taskflow/app/facades"
"taskflow/app/http/authuser"
"taskflow/app/listeners"
"taskflow/app/models"
"taskflow/app/policies"
"taskflow/config"
"taskflow/routes"
)
func Boot() contractsfoundation.Application {
return foundation.Setup().
+ WithJobs(Jobs).
WithRules(Rules).
WithSeeders(Seeders).
WithMigrations(Migrations).
WithRouting(func() {
routes.Api()
routes.Web()
}).
WithEvents(func() map[contractsevent.Event][]contractsevent.Listener {
return map[contractsevent.Event][]contractsevent.Listener{
&events.TaskAssigned{}: {&listeners.NotifyAssignee{}},
}
}).
WithProviders(Providers).
WithConfig(config.Boot).
WithCallback(func() {
facades.Schema().Extend(schema.Extension{
Models: []any{models.User{}, models.Task{}, models.Attachment{}, models.Notification{}, models.Export{}},
})
facades.RateLimiter().For("login", func(ctx http.Context) http.Limit {
email := strings.ToLower(strings.TrimSpace(ctx.Request().Input("email")))
return limit.PerMinute(5).By(email).Response(func(ctx http.Context) {
ctx.Response().Json(429, map[string]any{
"error": "Too many login attempts. Try again in a minute.",
}).Abort()
})
})
facades.Gate().Before(func(ctx context.Context, ability string, arguments map[string]any) contractsaccess.Response {
if user := authuser.From(ctx); user != nil && user.Role == enums.RoleManager {
return access.NewAllowResponse()
}
return nil
})
facades.Gate().Define(policies.ViewUsers, func(ctx context.Context, arguments map[string]any) contractsaccess.Response {
return access.NewDenyResponse("Only managers can see the users.")
})
taskPolicy := policies.NewTaskPolicy()
facades.Gate().Define(policies.CreateTask, taskPolicy.Create)
facades.Gate().Define(policies.UpdateTask, taskPolicy.Update)
facades.Gate().Define(policies.DeleteTask, taskPolicy.Delete)
attachmentPolicy := policies.NewAttachmentPolicy()
facades.Gate().Define(policies.CreateAttachment, attachmentPolicy.Create)
facades.Gate().Define(policies.ViewAttachment, attachmentPolicy.View)
facades.Gate().Define(policies.DeleteAttachment, attachmentPolicy.Delete)
}).
Create()
}
```
The job takes the export's id, writes the CSV to the disk from [File Storage](https://devchalk.com/goravel/14-file-storage-attachments#where-the-files-go) and marks the export done:
```go title="app/jobs/export_tasks.go" collapse={1-16} t3:sig=/"(export_tasks)"/ t2:id=/(exportID), ok := args/
package jobs
import (
"bytes"
"encoding/csv"
"errors"
"fmt"
"strconv"
"github.com/goravel/framework/contracts/queue"
frameworkerrors "github.com/goravel/framework/errors"
"taskflow/app/enums"
"taskflow/app/facades"
"taskflow/app/models"
)
type ExportTasks struct {
}
func NewExportTasks(exportID uint) (*ExportTasks, []queue.Arg) {
return &ExportTasks{}, []queue.Arg{
{Type: "uint", Value: exportID},
}
}
// Signature The name and signature of the job.
func (receiver *ExportTasks) Signature() string {
return "export_tasks"
}
// Handle Execute the job.
func (receiver *ExportTasks) Handle(args ...any) error {
exportID, ok := args[0].(uint)
if !ok {
return errors.New("export tasks: the argument must be the export id")
}
var export models.Export
err := facades.Orm().Query().FindOrFail(&export, exportID)
if errors.Is(err, frameworkerrors.OrmRecordNotFound) {
return nil
}
if err != nil {
return err
}
var tasks []models.Task
if err := facades.Orm().Query().With("Assignee").OrderBy("id").Find(&tasks); err != nil {
return err
}
rows := [][]string{{"id", "title", "status", "assignee", "due_at"}}
for _, task := range tasks {
assignee, dueAt := "", ""
if task.Assignee != nil {
assignee = task.Assignee.Name
}
if task.DueAt != nil {
dueAt = task.DueAt.ToDateTimeString()
}
rows = append(rows, []string{strconv.Itoa(int(task.ID)), task.Title, string(task.Status), assignee, dueAt})
}
var file bytes.Buffer
if err := csv.NewWriter(&file).WriteAll(rows); err != nil {
return err
}
path := fmt.Sprintf("exports/%d.csv", export.ID)
if err := facades.Storage().Put(path, file.String()); err != nil {
return err
}
export.Status = enums.ExportDone
export.Path = path
return facades.Orm().Query().Save(&export)
}
```
- `NewExportTasks` builds the job and its args together, as `NewTaskAssigned` does for the event in [Events & Listeners](https://devchalk.com/goravel/15-events-listeners-notifications#the-event). Every call then passes the export's id, and always as a `uint`.
- `Signature` is the job's name, `export_tasks`. The worker finds the job by it and prints it.
- `Handle` reads the export's id from its args and checks its type. Args have to be plain values, as with the queued listener, so the job gets the id and loads the export itself. If the export is gone, an error would only put a job that can never succeed in `failed_jobs`, so it returns `nil`.
- It writes one CSV row per task to `exports/.csv` and marks the export `done`.
- Returning an error tells the worker the job failed.
## Starting an export
`POST /exports` saves a pending export and sends the job. It then answers `202 Accepted`, which means the request was accepted but the work isn't finished. The client reads the status from `GET /exports/{id}`, and once it's `done`, `GET /exports/{id}/download` sends the file:
```go title="app/http/controllers/export_controller.go" collapse={1-28} collapse={51-70} collapse={73-86} t2:id=/NewExportTasks[(](export[.]ID)[)]/
package controllers
import (
"errors"
"fmt"
"mime"
"github.com/goravel/framework/contracts/http"
frameworkerrors "github.com/goravel/framework/errors"
"taskflow/app/enums"
"taskflow/app/facades"
"taskflow/app/http/authuser"
"taskflow/app/jobs"
"taskflow/app/models"
)
type ExportController struct {
// Dependent services
}
func NewExportController() *ExportController {
return &ExportController{
// Inject services
}
}
func (r *ExportController) Store(ctx http.Context) http.Response {
export := models.Export{
UserID: authuser.From(ctx).ID,
Status: enums.ExportPending,
}
if err := facades.Orm().WithContext(ctx).Query().Create(&export); err != nil {
facades.Log().Error(err)
return ctx.Response().Json(500, map[string]any{
"error": "Could not start the export.",
})
}
err := facades.Queue().Job(jobs.NewExportTasks(export.ID)).Dispatch()
if err != nil {
facades.Log().Error(err)
return ctx.Response().Json(500, map[string]any{
"error": "Could not start the export.",
})
}
return ctx.Response().Json(202, export)
}
func (r *ExportController) Show(ctx http.Context) http.Response {
var export models.Export
err := facades.Orm().WithContext(ctx).Query().
Where("user_id", authuser.From(ctx).ID).
FindOrFail(&export, ctx.Request().RouteInt("id"))
if errors.Is(err, frameworkerrors.OrmRecordNotFound) {
return ctx.Response().Json(404, map[string]any{
"error": "The export does not exist.",
})
}
if err != nil {
facades.Log().Error(err)
return ctx.Response().Json(500, map[string]any{
"error": "Could not load the export.",
})
}
return ctx.Response().Json(200, export)
}
func (r *ExportController) Download(ctx http.Context) http.Response {
var export models.Export
err := facades.Orm().WithContext(ctx).Query().
Where("user_id", authuser.From(ctx).ID).
FindOrFail(&export, ctx.Request().RouteInt("id"))
if errors.Is(err, frameworkerrors.OrmRecordNotFound) {
return ctx.Response().Json(404, map[string]any{
"error": "The export does not exist.",
})
}
if err != nil {
facades.Log().Error(err)
return ctx.Response().Json(500, map[string]any{
"error": "Could not load the export.",
})
}
if export.Status != enums.ExportDone {
return ctx.Response().Json(409, map[string]any{
"error": "The export is not ready yet.",
})
}
content, err := facades.Storage().GetBytes(export.Path)
if err != nil {
facades.Log().Error(err)
return ctx.Response().Json(500, map[string]any{
"error": "Could not load the export.",
})
}
disposition := mime.FormatMediaType("attachment", map[string]string{"filename": fmt.Sprintf("tasks-%d.csv", export.ID)})
return ctx.Response().
Header("Content-Disposition", disposition).
Data(200, "text/csv", content)
}
```
- `NewExportTasks` gives `Job` the job and its one arg, the export's id, and `Dispatch` puts the job on the queue.
- `Show` and `Download` only look among the user's own exports, so an export id that belongs to someone else gives `404`.
- `Download` answers `409 Conflict` until the export is done.
```diff lang="go" title="routes/api.go" collapse={1-15} collapse={22-39}
package routes
import (
"taskflow/app/facades"
"taskflow/app/http/controllers"
"taskflow/app/http/middleware"
"github.com/goravel/framework/contracts/route"
httpmiddleware "github.com/goravel/framework/http/middleware"
)
func Api() {
authController := controllers.NewAuthController()
taskController := controllers.NewTaskController()
userController := controllers.NewUserController()
attachmentController := controllers.NewAttachmentController()
notificationController := controllers.NewNotificationController()
+ exportController := controllers.NewExportController()
facades.Route().Prefix("api/v1").Group(func(router route.Router) {
router.Post("register", authController.Register)
router.Middleware(httpmiddleware.Throttle("login")).Post("login", authController.Login)
router.Post("refresh", authController.Refresh)
router.Middleware(&middleware.Auth{}).Group(func(router route.Router) {
router.Get("me", authController.Me)
router.Post("logout", authController.Logout)
router.Post("tasks", taskController.Store).Name("tasks.create")
router.Get("tasks/{id}", taskController.Show)
router.Get("tasks", taskController.Index)
router.Patch("tasks/{id}", taskController.Update)
router.Delete("tasks/{id}", taskController.Destroy)
router.Post("tasks/{id}/attachments", attachmentController.Store)
router.Get("tasks/{id}/attachments/{attachment}", attachmentController.Show)
router.Delete("tasks/{id}/attachments/{attachment}", attachmentController.Destroy)
router.Get("notifications", notificationController.Index)
router.Post("notifications/{id}/read", notificationController.Read)
+ router.Post("exports", exportController.Store)
+ router.Get("exports/{id}", exportController.Show)
+ router.Get("exports/{id}/download", exportController.Download)
+
router.Get("users", userController.Index)
router.Get("users/{id}", userController.Show)
})
})
}
```
## See it work
The run starts from the same setup as [Gates & Policies](https://devchalk.com/goravel/12-gates-policies#the-rules):
1. Reset the database with `migrate:fresh --seed`, which adds Ana as the manager, plus three members and seven tasks.
2. Delete `storage/app/exports`, so files from an earlier run can't stop the failure later on.
3. Register Ben. He gets id 5.
4. Log in as Ana and as Ben, and save each token in a variable, `ANA` and `BEN`.
The export is pending when the request returns, and done a moment later:
```bash
$ curl -X POST http://localhost:3000/api/v1/exports -H "Authorization: Bearer $ANA"
{…,"id":1,"user_id":1,"status":"pending"}
$ sleep 2
$ curl http://localhost:3000/api/v1/exports/1 -H "Authorization: Bearer $ANA"
{…,"id":1,"user_id":1,"status":"done"}
$ curl -D - http://localhost:3000/api/v1/exports/1/download -H "Authorization: Bearer $ANA"
HTTP/1.1 200 OK
Content-Disposition: attachment; filename=tasks-1.csv
Content-Length: 466
Content-Type: text/csv
Date: …
id,title,status,assignee,due_at
1,Review the pull request,done,Francesco Bradley,2026-11-04 09:00:00
2,Review the pull request,pending,Francesco Bradley,2026-11-25 09:00:00
…
$ curl http://localhost:3000/api/v1/exports/1 -H "Authorization: Bearer $BEN"
{"error":"The export does not exist."}
```
The seeder makes up names and dates, so the CSV rows differ after each reset.
The worker runs inside the app, so its lines show up next to the server's own output. It prints one line when a job starts and one when it ends, each with the job's signature:
```text t3:sig=/(export_tasks) [.]+ RUNNING/
2026-10-27 00:09:53.215 export_tasks ............................... RUNNING
2026-10-27 00:09:53.222 export_tasks .......................... 6.121ms DONE
```
## When the job fails
The job can't create a file in a read-only folder, so `chmod 555` on `storage/app/exports` makes it fail. `POST /exports` still answers `202`, because it only queues the job, but the export stays pending and `Download` refuses the file:
```bash
$ chmod 555 storage/app/exports
$ curl -X POST http://localhost:3000/api/v1/exports -H "Authorization: Bearer $ANA"
{…,"id":2,"user_id":1,"status":"pending"}
$ curl http://localhost:3000/api/v1/exports/2 -H "Authorization: Bearer $ANA"
{…,"id":2,"user_id":1,"status":"pending"}
$ curl http://localhost:3000/api/v1/exports/2/download -H "Authorization: Bearer $ANA"
{"error":"The export is not ready yet."}
```
The worker tries the job once and gives up:
```text
2026-10-27 00:09:55.231 export_tasks ............................... RUNNING
2026-10-27 00:09:55.235 export_tasks .......................... 3.839ms FAIL
```
The worker saves a failed job in the `failed_jobs` table, with its args and the error. `queue:failed` lists those jobs but not the error, so `psql` reads it from the table:
```bash
$ go run . artisan queue:failed
2026-10-27 00:09:57 00950ba2-2c5b-43dd-9cd8-d757ce5d198b ..... redis@default
$ docker compose exec postgres psql -U goravel taskflow -At -c "select exception from failed_jobs"
open …/storage/app/exports/2.csv: permission denied
```
Nothing marks the export as failed, so it stays pending. Once the folder is writable again, `queue:retry` takes the job's uuid, the one `queue:failed` listed, and puts that job back on the queue. The worker then runs it and finishes the export:
```bash
$ chmod 755 storage/app/exports
$ go run . artisan queue:retry 00950ba2-2c5b-43dd-9cd8-d757ce5d198b
INFO Pushing failed queue jobs back onto the queue
00950ba2-2c5b-43dd-9cd8-d757ce5d198b ........................... 2.91ms DONE
$ curl http://localhost:3000/api/v1/exports/2 -H "Authorization: Bearer $ANA"
{…,"id":2,"user_id":1,"status":"done"}
```
`queue:retry` also takes several uuids at once, and with none it retries every failed job.
## Trying again by itself
Some failures go away after a few seconds, like a database that's restarting, so `ShouldRetry` lets the job try again:
```diff lang="go" title="app/jobs/export_tasks.go" collapse={12-78} t1:retry=/(attempt < 3, 5 [*] time[.]Second)/
package jobs
import (
"bytes"
"encoding/csv"
"errors"
"fmt"
"strconv"
+ "time"
"github.com/goravel/framework/contracts/queue"
frameworkerrors "github.com/goravel/framework/errors"
"taskflow/app/enums"
"taskflow/app/facades"
"taskflow/app/models"
)
type ExportTasks struct {
}
func NewExportTasks(exportID uint) (*ExportTasks, []queue.Arg) {
return &ExportTasks{}, []queue.Arg{
{Type: "uint", Value: exportID},
}
}
// Signature The name and signature of the job.
func (receiver *ExportTasks) Signature() string {
return "export_tasks"
}
// Handle Execute the job.
func (receiver *ExportTasks) Handle(args ...any) error {
exportID, ok := args[0].(uint)
if !ok {
return errors.New("export tasks: the argument must be the export id")
}
var export models.Export
err := facades.Orm().Query().FindOrFail(&export, exportID)
if errors.Is(err, frameworkerrors.OrmRecordNotFound) {
return nil
}
if err != nil {
return err
}
var tasks []models.Task
if err := facades.Orm().Query().With("Assignee").OrderBy("id").Find(&tasks); err != nil {
return err
}
rows := [][]string{{"id", "title", "status", "assignee", "due_at"}}
for _, task := range tasks {
assignee, dueAt := "", ""
if task.Assignee != nil {
assignee = task.Assignee.Name
}
if task.DueAt != nil {
dueAt = task.DueAt.ToDateTimeString()
}
rows = append(rows, []string{strconv.Itoa(int(task.ID)), task.Title, string(task.Status), assignee, dueAt})
}
var file bytes.Buffer
if err := csv.NewWriter(&file).WriteAll(rows); err != nil {
return err
}
path := fmt.Sprintf("exports/%d.csv", export.ID)
if err := facades.Storage().Put(path, file.String()); err != nil {
return err
}
export.Status = enums.ExportDone
export.Path = path
return facades.Orm().Query().Save(&export)
}
+
+// ShouldRetry gives a failed export two more tries, five seconds apart.
+func (receiver *ExportTasks) ShouldRetry(err error, attempt int) (bool, time.Duration) {
+ return attempt < 3, 5 * time.Second
+}
```
After each failed try, the worker calls `ShouldRetry` with the error and the try's number, starting at 1. `ShouldRetry` returns whether to try again and how long to wait. Here `attempt < 3, 5 * time.Second` means another try after tries 1 and 2, each five seconds later. After the third failed try, the worker saves the job in `failed_jobs`.
The worker runs one job at a time, and it sleeps through each five-second wait itself. So a job queued behind a failing export has to wait for all three tries. The block below makes the folder read-only again, starts an export, then assigns a task, which queues a notification:
```bash
$ chmod 555 storage/app/exports
$ curl -s -o /dev/null -w "%{http_code}\n" -X POST http://localhost:3000/api/v1/exports -H "Authorization: Bearer $ANA"
202
$ curl -s -o /dev/null -w "%{http_code}\n" -X POST http://localhost:3000/api/v1/tasks \
-H "Authorization: Bearer $ANA" \
-H "Content-Type: application/json" \
-d '{"title":"Fix the login page","description":"The error message overlaps the button.","assigned_to":5}'
201
$ sleep 2
$ curl http://localhost:3000/api/v1/notifications -H "Authorization: Bearer $BEN"
{"data":[],"meta":{"page":1,"per_page":15,"total":0}}
$ sleep 10
$ curl http://localhost:3000/api/v1/notifications -H "Authorization: Bearer $BEN"
{"data":[{…,"id":1,"user_id":5,"task_id":8,"message":"Ana assigned you \"Fix the login page\"","read_at":null}],"meta":{"page":1,"per_page":15,"total":1}}
```
The notification only ran after the export gave up, as the worker's log shows:
```text t2:late=/(notify_assignee) [.]+ RUNNING/
2026-10-27 00:10:06.816 export_tasks ............................... RUNNING
2026-10-27 00:10:16.839 export_tasks .......................... 6.905ms FAIL
2026-10-27 00:10:16.842 notify_assignee ............................ RUNNING
2026-10-27 00:10:16.847 notify_assignee ........................ 4.77ms DONE
```
The figure steps through both cases, an export that works and this one:
*An export that works, then one that fails three times while a notification waits behind it.*
An export that works: the client sends POST /exports, the API saves a pending export in Postgres, puts an export_tasks job on Redis and answers 202. The worker takes the job, loads the tasks, writes the CSV to the disk and marks the export done. An export that fails: the API answers 202 as before, but the worker can't write the file, waits five seconds and tries again, three times, then saves the job in failed_jobs. The notification job waits on Redis the whole time and runs only after the export gives up.
## Two jobs at once
`concurrent` is a key on a connection in `config/queue.go`. It sets how many jobs the worker runs at the same time, and it defaults to 1. The redis connection doesn't set it, so it runs one job at a time until it's set to 2:
```diff lang="go" title="config/queue.go" collapse={1-21} collapse={36-48}
package config
import (
"github.com/goravel/framework/contracts/queue"
redisfacades "github.com/goravel/redis/facades"
"taskflow/app/facades"
)
func init() {
config := facades.Config()
config.Add("queue", map[string]any{
"default": config.Env("QUEUE_CONNECTION", "redis"),
// Queue Connections
//
// Here you may configure the connection information for each server that is used by your application.
// Drivers: "sync", "database", "custom"
"connections": map[string]any{
"sync": map[string]any{
"driver": "sync",
},
"database": map[string]any{
"driver": "database",
"connection": config.Env("DB_CONNECTION"),
"queue": "default",
"concurrent": 1,
},
"redis": map[string]any{
"driver": "custom",
"connection": "default",
"queue": "default",
+ "concurrent": 2,
"via": func() (queue.Driver, error) {
return redisfacades.Queue("redis") // The `redis` value is the key of `connections`
},
},
},
// Failed Queue Jobs
//
// These options configure the behavior of failed queue job logging so you
// can control how and where failed jobs are stored.
"failed": map[string]any{
"database": config.Env("DB_CONNECTION"),
"table": "failed_jobs",
},
})
}
```
With the same two requests, the notification is there after 2 seconds. In the log, it starts at once, while the export is still retrying:
```text t2:atonce=/(notify_assignee) [.]+ RUNNING/
2026-10-27 00:10:25.057 export_tasks ............................... RUNNING
2026-10-27 00:10:25.057 notify_assignee ............................ RUNNING
2026-10-27 00:10:25.074 notify_assignee ...................... 17.074ms DONE
2026-10-27 00:10:35.08 export_tasks ........................... 6.471ms FAIL
```
Make the folder writable again with `chmod 755 storage/app/exports`.
A queue of its own for exports:
Exports could also go on a queue of their own with `OnQueue`:
```go
facades.Queue().Job(jobs.NewExportTasks(export.ID)).OnQueue("exports").Dispatch()
```
The worker that `app.Start()` runs reads one queue, the one named by the default connection's `queue` key. That's `default` in TaskFlow, as the start-up line says: `Processing jobs from [redis] connection and [default] queue`. A job on `exports` needs a second worker for that queue, which a [runner](https://www.goravel.dev/architecture-concepts/service-providers#runners) added in `WithRunners` can start. With its own worker, a slow export never holds up the jobs on `default`.
Sending a job later with Delay:
`Delay` sends a job later:
```go
facades.Queue().Job(jobs.NewExportTasks(export.ID)).Delay(time.Now().Add(time.Hour)).Dispatch()
```
On redis, the job waits until then. On `sync`, which the tests use, `Dispatch` itself sleeps until then, so a test with that delay would take an hour. An export should start as soon as it's asked for, so TaskFlow doesn't use `Delay`.
## Tests
The tests are in [`tests/feature/export_test.go`](https://github.com/krishankumar01/goravel-taskflow/blob/part-16/tests/feature/export_test.go). They use the `sync` connection from [Events & Listeners](https://devchalk.com/goravel/15-events-listeners-notifications#tests), so the job runs inside `POST /exports`. They check that:
- the export is done
- the download is the CSV
- other users get `404`
- a pending export can't be downloaded
```bash
$ go test ./tests/... --env=.env.testing
? taskflow/tests [no test files]
ok taskflow/tests/feature 1.542s
ok taskflow/tests/unit 0.398s
```
Exports now run in the background and retry when they fail. An export that fails every try still shows as pending.
---
Previous: [15. Events & Listeners](https://devchalk.com/goravel/15-events-listeners-notifications.md)
Next: [17. Mail & Templates](https://devchalk.com/goravel/17-mail-templates.md)
Series: [Goravel](https://devchalk.com/goravel.md)
# Mail & Templates
> Emails for new assignments and finished exports.
Goravel series, part 17 of 20. By Krishan Kumar. Published 2026-10-28.
Source: https://devchalk.com/goravel/17-mail-templates
Code: https://github.com/krishankumar01/goravel-taskflow/tree/part-17
So far an assignee learns about a new task only from `GET /notifications`, and the owner of an export has to ask `GET /exports/{id}` until its file is ready. To solve it, we are going to notify them a different way: by email. Each email will have an HTML part and a plain-text part, and it will use templates to add the dynamic data.
Start from the Queues & Jobs code:
```bash
git clone https://github.com/krishankumar01/goravel-taskflow
cd goravel-taskflow
git checkout part-16
cp .env.example .env
go run . artisan key:generate
go run . artisan jwt:secret
docker compose up -d
go run . artisan migrate:fresh --seed
docker compose exec postgres psql -U goravel -d taskflow -c "CREATE DATABASE taskflow_test"
go install github.com/air-verse/air@latest
air
```
## Mail on a laptop
Goravel sends [mail](https://www.goravel.dev/digging-deeper/mail) over SMTP, so to work in this part we are going to use a tool for development called [Mailpit](https://mailpit.axllent.org). It accepts every email and shows it on a web page instead of delivering it to the recipient.
To use Goravel's mail package, let's install it first:
```bash
$ go run . artisan package:install Mail
INFO Mail depends on Log, Cache, Orm, Schema, DB, Queue, they will be installed simultaneously
SUCCESS Facade Mail installed successfully
```
`package:install` adds `config/mail.go`, the `facades.Mail()` facade and the mail service provider. It also adds empty `MAIL_` keys to `.env` and `.env.example`, which we fill in below.
Next, we need to add Mailpit to `docker-compose.yml`, so that it runs on our machine next to Postgres and Redis:
```diff lang="yaml" title="docker-compose.yml" collapse={1-19} t1:auth=/(MP_SMTP_AUTH_[A-Z_]+)/
services:
postgres:
image: postgres:15-alpine
restart: always
environment:
POSTGRES_DB: taskflow
POSTGRES_USER: goravel
POSTGRES_PASSWORD: secret
ports:
- "5433:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
redis:
image: redis:alpine
restart: always
ports:
- "6380:6379"
volumes:
- redis_data:/data
+ mailpit:
+ image: axllent/mailpit:v1.27
+ restart: always
+ environment:
+ MP_SMTP_AUTH_ACCEPT_ANY: 1
+ MP_SMTP_AUTH_ALLOW_INSECURE: 1
+ ports:
+ - "1025:1025"
+ - "8025:8025"
+
volumes:
postgres_data:
redis_data:
```
We need the two settings because Goravel always logs in to the SMTP server on a port other than 465 or 587, even with an empty username. Mailpit offers no login by default, so Go's SMTP client stops with `smtp: server doesn't support AUTH`. The settings make Mailpit accept any username and password without TLS.
Now we point TaskFlow at Mailpit with the `MAIL_` keys. Fill them in `.env` the same way, and add them to `.env.testing` so the tests send to Mailpit too:
```diff lang="ini" title=".env.example" collapse={1-28}
APP_NAME=Goravel
APP_ENV=local
APP_KEY=
APP_DEBUG=true
LOG_CHANNEL=stack
LOG_LEVEL=debug
SESSION_DRIVER=file
SESSION_LIFETIME=120
APP_URL=http://localhost
APP_HOST=127.0.0.1
APP_PORT=3000
JWT_SECRET=
DB_CONNECTION=postgres
DB_HOST=127.0.0.1
DB_PORT=5433
DB_DATABASE=taskflow
DB_USERNAME=goravel
DB_PASSWORD=secret
FILESYSTEM_DISK=local
QUEUE_CONNECTION=redis
REDIS_HOST=127.0.0.1
REDIS_PASSWORD=
REDIS_PORT=6380
+
+MAIL_HOST=127.0.0.1
+MAIL_PORT=1025
+MAIL_USERNAME=
+MAIL_PASSWORD=
+MAIL_FROM_ADDRESS=taskflow@example.com
+MAIL_FROM_NAME=TaskFlow
```
- `MAIL_HOST` and `MAIL_PORT` are where Mailpit listens for SMTP.
- `MAIL_FROM_ADDRESS` and `MAIL_FROM_NAME` fill the From line of every email.
- `MAIL_USERNAME` and `MAIL_PASSWORD` stay empty, because Mailpit accepts any login.
Then start Mailpit with `docker compose up -d mailpit`. Its inbox is at http://localhost:8025, where every email from this part shows up.
## The first email
We start with the assignment email. TaskFlow already fires `TaskAssigned` whenever someone assigns a task, from [Events & Listeners](https://devchalk.com/goravel/15-events-listeners-notifications#the-listener). So we are going to add a second listener for this event, called `EmailAssignee`:
```bash
$ go run . artisan make:listener EmailAssignee
SUCCESS Listener created successfully
```
```go title="app/listeners/email_assignee.go" collapse={1-13} collapse={17-28} t1:to=/^\s+(To)[(]/ t2:subject=/^\s+(Subject)[(]/ t3:content=/^\s+(Content)[(]/ t4:send=/^\s+(Send)[(]/
package listeners
import (
"errors"
"fmt"
"github.com/goravel/framework/contracts/event"
"github.com/goravel/framework/contracts/mail"
frameworkerrors "github.com/goravel/framework/errors"
"taskflow/app/facades"
"taskflow/app/models"
)
type EmailAssignee struct {
}
func (receiver *EmailAssignee) Signature() string {
return "email_assignee"
}
func (receiver *EmailAssignee) Queue(args ...any) event.Queue {
return event.Queue{
Enable: true,
Connection: "",
Queue: "",
}
}
func (receiver *EmailAssignee) Handle(args ...any) error {
taskID, ok1 := args[0].(uint)
assignedTo, ok2 := args[1].(uint)
assignedBy, ok3 := args[2].(uint)
if !ok1 || !ok2 || !ok3 {
return errors.New("email assignee: the ids must be uints")
}
if assignedTo == assignedBy {
return nil
}
var task models.Task
err := facades.Orm().Query().FindOrFail(&task, taskID)
if errors.Is(err, frameworkerrors.OrmRecordNotFound) {
return nil
}
if err != nil {
return err
}
var assignee, manager models.User
if err := facades.Orm().Query().FindOrFail(&assignee, assignedTo); err != nil {
return err
}
if err := facades.Orm().Query().FindOrFail(&manager, assignedBy); err != nil {
return err
}
return facades.Mail().
To([]string{assignee.Email}).
Subject(fmt.Sprintf("%s assigned you a task", manager.Name)).
Content(mail.Content{Text: fmt.Sprintf("%s assigned you %q.", manager.Name, task.Title)}).
Send()
}
```
`Handle` makes the same checks as `NotifyAssignee`, so a manager who takes a task themselves gets no email.
We build the email with [`facades.Mail()`](https://www.goravel.dev/digging-deeper/mail#send-mail), which starts a new email on every call. It gives a fluent API: each method sets one part of the email and returns the email again, so the calls chain until [`Send`](https://github.com/goravel/framework/blob/v1.18.0/contracts/mail/mail.go#L19):
- [`To`](https://github.com/goravel/framework/blob/v1.18.0/contracts/mail/mail.go#L23) takes the recipients' addresses.
- [`Subject`](https://github.com/goravel/framework/blob/v1.18.0/contracts/mail/mail.go#L21) sets the subject line.
- [`Content`](https://github.com/goravel/framework/blob/v1.18.0/contracts/mail/mail.go#L11) sets the body. With only `Text`, the email has a plain-text part and no HTML.
- [`Send`](https://github.com/goravel/framework/blob/v1.18.0/contracts/mail/mail.go#L19) sends it over SMTP right away.
The chain also has [`Cc`](https://github.com/goravel/framework/blob/v1.18.0/contracts/mail/mail.go#L9), [`Bcc`](https://github.com/goravel/framework/blob/v1.18.0/contracts/mail/mail.go#L7), [`Attach`](https://github.com/goravel/framework/blob/v1.18.0/contracts/mail/mail.go#L5), [`Headers`](https://github.com/goravel/framework/blob/v1.18.0/contracts/mail/mail.go#L15) and [`From`](https://github.com/goravel/framework/blob/v1.18.0/contracts/mail/mail.go#L13). This email doesn't need them, and without `From` it uses `MAIL_FROM_ADDRESS` and `MAIL_FROM_NAME`.
`Queue` returns `Enable: true`, as in `NotifyAssignee`, so the listener runs on the worker and sending the email doesn't hold up the request that assigned the task.
A listener runs only once `WithEvents` lists it, so we add it after `NotifyAssignee` in `bootstrap/app.go`:
```diff lang="go" title="bootstrap/app.go" collapse={1-36} collapse={43-83}
package bootstrap
import (
"context"
"strings"
"github.com/goravel/framework/auth/access"
contractsaccess "github.com/goravel/framework/contracts/auth/access"
"github.com/goravel/framework/contracts/database/schema"
contractsevent "github.com/goravel/framework/contracts/event"
contractsfoundation "github.com/goravel/framework/contracts/foundation"
"github.com/goravel/framework/contracts/http"
"github.com/goravel/framework/foundation"
"github.com/goravel/framework/http/limit"
"taskflow/app/enums"
"taskflow/app/events"
"taskflow/app/facades"
"taskflow/app/http/authuser"
"taskflow/app/listeners"
"taskflow/app/models"
"taskflow/app/policies"
"taskflow/config"
"taskflow/routes"
)
func Boot() contractsfoundation.Application {
return foundation.Setup().
WithJobs(Jobs).
WithRules(Rules).
WithSeeders(Seeders).
WithMigrations(Migrations).
WithRouting(func() {
routes.Api()
routes.Web()
}).
WithEvents(func() map[contractsevent.Event][]contractsevent.Listener {
return map[contractsevent.Event][]contractsevent.Listener{
- &events.TaskAssigned{}: {&listeners.NotifyAssignee{}},
+ &events.TaskAssigned{}: {&listeners.NotifyAssignee{}, &listeners.EmailAssignee{}},
}
}).
WithProviders(Providers).
WithConfig(config.Boot).
WithCallback(func() {
facades.Schema().Extend(schema.Extension{
Models: []any{models.User{}, models.Task{}, models.Attachment{}, models.Notification{}, models.Export{}},
})
facades.RateLimiter().For("login", func(ctx http.Context) http.Limit {
email := strings.ToLower(strings.TrimSpace(ctx.Request().Input("email")))
return limit.PerMinute(5).By(email).Response(func(ctx http.Context) {
ctx.Response().Json(429, map[string]any{
"error": "Too many login attempts. Try again in a minute.",
}).Abort()
})
})
facades.Gate().Before(func(ctx context.Context, ability string, arguments map[string]any) contractsaccess.Response {
if user := authuser.From(ctx); user != nil && user.Role == enums.RoleManager {
return access.NewAllowResponse()
}
return nil
})
facades.Gate().Define(policies.ViewUsers, func(ctx context.Context, arguments map[string]any) contractsaccess.Response {
return access.NewDenyResponse("Only managers can see the users.")
})
taskPolicy := policies.NewTaskPolicy()
facades.Gate().Define(policies.CreateTask, taskPolicy.Create)
facades.Gate().Define(policies.UpdateTask, taskPolicy.Update)
facades.Gate().Define(policies.DeleteTask, taskPolicy.Delete)
attachmentPolicy := policies.NewAttachmentPolicy()
facades.Gate().Define(policies.CreateAttachment, attachmentPolicy.Create)
facades.Gate().Define(policies.ViewAttachment, attachmentPolicy.View)
facades.Gate().Define(policies.DeleteAttachment, attachmentPolicy.Delete)
}).
Create()
}
```
To try it, we start from the same setup as [Queues & Jobs](https://devchalk.com/goravel/16-queues-jobs-task-export#see-it-work):
1. Reset the database with `migrate:fresh --seed`, which adds Ana as the manager, plus three members and seven tasks.
2. Register Ben. He gets id 5.
3. Log in as Ana and as Ben, and save each token in a variable, `ANA` and `BEN`.
Now when Ana assigns a task to Ben, an email arrives in Mailpit. `/view/latest.txt` shows the plain-text part of the newest email:
```bash
$ curl -X POST http://localhost:3000/api/v1/tasks \
-H "Authorization: Bearer $ANA" \
-H "Content-Type: application/json" \
-d '{"title":"Fix the login page","description":"The error message overlaps the button & the logo.","assigned_to":5,"due_at":"2026-11-06 09:00:00"}'
{…,"id":8,…,"title":"Fix the login page",…}
$ curl http://localhost:8025/view/latest.txt
Ana assigned you "Fix the login page".
```
## A Mailable
Building the email inline in the listener works for one line of text. But real emails are more complex, with HTML and styles. And if we send the same email from more than one place, we have to write the same code in each of them. So we are going to use a Mailable, a struct that holds all of this for one email. TaskFlow's unit test, for one, builds the same assignment email without sending it:
```bash
$ go run . artisan make:mail Assignment
SUCCESS Mail created successfully
```
```go title="app/mails/assignment.go" collapse={1-9} t3:task=/"(Task)":/
package mails
import (
"fmt"
"github.com/goravel/framework/contracts/mail"
"taskflow/app/models"
)
type Assignment struct {
task models.Task
assignee models.User
by models.User
}
func NewAssignment(task models.Task, assignee, by models.User) *Assignment {
return &Assignment{task: task, assignee: assignee, by: by}
}
func (receiver *Assignment) Headers() map[string]string {
return map[string]string{}
}
func (receiver *Assignment) Attachments() []string {
return []string{}
}
func (receiver *Assignment) Content() *mail.Content {
return &mail.Content{
HtmlView: "assignment.html",
TextView: "assignment.txt",
With: map[string]any{
"Name": receiver.assignee.Name,
"By": receiver.by.Name,
"Task": receiver.task,
},
}
}
func (receiver *Assignment) Envelope() *mail.Envelope {
return &mail.Envelope{
To: []string{receiver.assignee.Email},
Subject: fmt.Sprintf("%s assigned you a task", receiver.by.Name),
}
}
func (receiver *Assignment) Queue() *mail.Queue {
return &mail.Queue{}
}
```
- `NewAssignment` takes the task and both users, so the Mailable has everything the email shows.
- `Content` names the two views, one for the HTML part and one for the text part. `With` is the data they can use.
- `Headers`, `Attachments` and `Queue` come from `make:mail` and stay empty. `Queue` matters later, when we queue the export email.
In the listener, `Send` now takes the Mailable in place of the inline calls:
```diff lang="go" title="app/listeners/email_assignee.go" collapse={15-55}
package listeners
import (
"errors"
- "fmt"
"github.com/goravel/framework/contracts/event"
- "github.com/goravel/framework/contracts/mail"
frameworkerrors "github.com/goravel/framework/errors"
"taskflow/app/facades"
+ "taskflow/app/mails"
"taskflow/app/models"
)
type EmailAssignee struct {
}
func (receiver *EmailAssignee) Signature() string {
return "email_assignee"
}
func (receiver *EmailAssignee) Queue(args ...any) event.Queue {
return event.Queue{
Enable: true,
Connection: "",
Queue: "",
}
}
func (receiver *EmailAssignee) Handle(args ...any) error {
taskID, ok1 := args[0].(uint)
assignedTo, ok2 := args[1].(uint)
assignedBy, ok3 := args[2].(uint)
if !ok1 || !ok2 || !ok3 {
return errors.New("email assignee: the ids must be uints")
}
if assignedTo == assignedBy {
return nil
}
var task models.Task
err := facades.Orm().Query().FindOrFail(&task, taskID)
if errors.Is(err, frameworkerrors.OrmRecordNotFound) {
return nil
}
if err != nil {
return err
}
var assignee, manager models.User
if err := facades.Orm().Query().FindOrFail(&assignee, assignedTo); err != nil {
return err
}
if err := facades.Orm().Query().FindOrFail(&manager, assignedBy); err != nil {
return err
}
- return facades.Mail().
- To([]string{assignee.Email}).
- Subject(fmt.Sprintf("%s assigned you a task", manager.Name)).
- Content(mail.Content{Text: fmt.Sprintf("%s assigned you %q.", manager.Name, task.Title)}).
- Send()
+ return facades.Mail().Send(mails.NewAssignment(task, assignee, manager))
}
```
The listener no longer deals with the email's text. That now lives inside `Assignment`, so it changes in one place.
## Views
In a Mailable, or in an email built with the chain, the body goes in `Content`. It can be inline, with `Text` and `Html`, or as complex as a full HTML page. For that, Goravel has email views, set with `HtmlView` and `TextView`, which the template engine set in `config/mail.go` renders. Its default engine, `html`, looks for the views in `resources/views/mail` and reads them as Go templates. The `With` data fills them: `{{.Name}}` prints the `Name` value, and `{{.Task.Title}}` prints a field of the task:
```html title="resources/views/mail/assignment.html" t3:task=/[{][{][.](Task)[.]Title/
Hi {{.Name}},
{{.By}} assigned you {{.Task.Title}}.
{{if .Task.DueAt}}It's due {{.Task.DueAt.ToDateTimeString}}.
{{end}}
{{.Task.Description}}
```
```text title="resources/views/mail/assignment.txt"
Hi {{.Name}},
{{.By}} assigned you "{{.Task.Title}}".
{{if .Task.DueAt}}It's due {{.Task.DueAt.ToDateTimeString}}.
{{end}}
{{.Task.Description}}
```
- `{{if .Task.DueAt}}` prints the due-date line only when the task has a due date.
- `{{.Task.DueAt.ToDateTimeString}}` calls a method on the date, the one the export uses for its CSV.
- Goravel renders the views with `html/template`, which escapes the values it prints, so a title with a `<` can't add HTML to the email.
The same assignment now arrives with both parts, but the last line of the text part shows `&` where the description has `&`:
```text
Hi Ben,
Ana assigned you "Fix the login page".
It's due 2026-11-06 09:00:00.
The error message overlaps the button & the logo.
```
Goravel's engine has two limitations for these emails:
- It renders the text view with `html/template` too, so it escapes the `&` as if the text were HTML.
- It parses each view from its own file, so the export email can't share a layout with this one.
## Another template engine
We can get past both by swapping the engine. Goravel's `custom` driver plugs in any template engine with a `Render(name, data)` method. We use [pongo2](https://github.com/flosch/pongo2), a Go engine with Django's template syntax, because its views can extend a layout and switch escaping off for the text part:
```bash
$ go get github.com/flosch/pongo2/v6
```
To plug pongo2 in, we write a small type in `app/mails` that hands each view to pongo2:
```go title="app/mails/pongo.go" collapse={1-7}
package mails
import (
"errors"
"github.com/flosch/pongo2/v6"
)
type Pongo struct {
views *pongo2.TemplateSet
}
func NewPongo(dir string) *Pongo {
return &Pongo{views: pongo2.NewSet("mail", pongo2.MustNewLocalFileSystemLoader(dir))}
}
func (receiver *Pongo) Render(name string, data any) (string, error) {
with, ok := data.(map[string]any)
if !ok {
return "", errors.New("pongo: the mail's With must be a map")
}
view, err := receiver.views.FromCache(name)
if err != nil {
return "", err
}
return view.Execute(pongo2.Context(with))
}
```
- `NewPongo` loads the views from one folder, `resources/mail`.
- `FromCache` parses a view the first time it's used and keeps it for later emails.
- `Render` gets the Mailable's `With` as `any`, but pongo2 needs a map, so any other type returns an error.
The views move to `resources/mail` and change to pongo2's syntax. First comes the layout, which every HTML email shares. It's built from HTML tables with inline styles, because email clients show those more reliably than a style sheet. `{% block content %}` marks where each view goes:
```html title="resources/mail/layout.html"
| TaskFlow |
| {% block content %}{% endblock %} |
| You get this email because you have a TaskFlow account. |
|
```
```html title="resources/mail/assignment.html" t3:task=/[{][{] (Task)[.]Title/
{% extends "layout.html" %}
{% block content %}
Hi {{ Name }},
{{ By }} assigned you {{ Task.Title }}.
{% if Task.DueAt %}It's due {{ Task.DueAt.ToDateTimeString }}.
{% endif %}
{{ Task.Description }}
{% endblock %}
```
```text title="resources/mail/assignment.txt"
{% autoescape off %}Hi {{ Name }},
{{ By }} assigned you "{{ Task.Title }}".
{% if Task.DueAt %}It's due {{ Task.DueAt.ToDateTimeString }}.
{% endif %}
{{ Task.Description }}{% endautoescape %}
```
- `{% extends "layout.html" %}` puts the view's `content` block into the layout.
- `{{ Task.Title }}` prints a value, and pongo2 escapes it for HTML, as `html/template` does.
- `{% if Task.DueAt %}` is false when `DueAt` is nil. `{{ Task.DueAt.ToDateTimeString }}` calls the method, with no parentheses.
- `{% autoescape off %}` turns that escaping off for the text view, so the `&` stays as typed.
Last, we need to tell Goravel to use our engine. We can register the custom engine in `config/mail.go` as `pongo`, with the `custom` driver. Goravel calls its `via` function once to get the engine, and `default` makes it the one every email uses:
```diff lang="go" title="config/mail.go" collapse={14-50}
package config
import (
+ "github.com/goravel/framework/contracts/mail"
+
"taskflow/app/facades"
+ "taskflow/app/mails"
)
func init() {
config := facades.Config()
config.Add("mail", map[string]any{
// SMTP Host Address
//
// Here you may provide the host address of the SMTP server used by your
// applications. A default option is provided that is compatible with
// the Mailgun mail service which will provide reliable deliveries.
"host": config.Env("MAIL_HOST", ""),
// SMTP Host Port
//
// This is the SMTP port used by your application to deliver e-mails to
// users of the application. Like the host we have set this value to
// stay compatible with the Mailgun e-mail application by default.
"port": config.Env("MAIL_PORT", 587),
// --------------------------------------------------------------------------
// Global "From" Address
// --------------------------------------------------------------------------
//
// You may wish for all e-mails sent by your application to be sent from
// the same address. Here, you may specify a name and address that is
// used globally for all e-mails that are sent by your application.
"from": map[string]any{
"address": config.Env("MAIL_FROM_ADDRESS", "hello@example.com"),
"name": config.Env("MAIL_FROM_NAME", "Example"),
},
// SMTP Server Username
//
// If your SMTP server requires a username for authentication, you should
// set it here. This will get used to authenticate with your server on
// connection. You may also set the "password" value below this one.
"username": config.Env("MAIL_USERNAME"),
"password": config.Env("MAIL_PASSWORD"),
// Template Configuration
//
// This controls template rendering for email views. Template engines are cached
// globally and support both built-in drivers and custom implementations via factories.
//
// Available Drivers: "html", "custom"
"template": map[string]any{
- "default": config.Env("MAIL_TEMPLATE_ENGINE", "html"),
+ "default": config.Env("MAIL_TEMPLATE_ENGINE", "pongo"),
"engines": map[string]any{
"html": map[string]any{
"driver": "html",
"path": config.Env("MAIL_VIEWS_PATH", "resources/views/mail"),
},
- // Example custom template engine:
- // "blade": map[string]any{
- // "driver": "custom",
- // "via": func() (mail.Template, error) {
- // return NewBladeTemplateEngine(), nil
- // },
- // },
+ "pongo": map[string]any{
+ "driver": "custom",
+ "via": func() (mail.Template, error) {
+ return mails.NewPongo(facades.App().ResourcePath("mail")), nil
+ },
+ },
},
},
})
}
```
The same assignment now keeps its `&`, and the HTML part in Mailpit has the TaskFlow header and footer around it:
```bash
$ curl http://localhost:8025/view/latest.txt
Hi Ben,
Ana assigned you "Fix the login page".
It's due 2026-11-06 09:00:00.
The error message overlaps the button & the logo.
```
## Queueing mail
Next, we email the owner of an export once its file is ready, so the email goes at the end of the export job. In `EmailAssignee` we used `Send`, because sending the email is the only thing that listener does. The export job is different, because it also builds the file. If we use `Send` here and SMTP fails, the whole export fails, and every retry builds the file again. So instead we use `Queue`, which sends the email as a job of its own:
```diff lang="go" title="app/jobs/export_tasks.go" collapse={1-13} collapse={19-76} t2:queue=/(Queue)[(]mails/
package jobs
import (
"bytes"
"encoding/csv"
"errors"
"fmt"
"strconv"
"time"
"github.com/goravel/framework/contracts/queue"
frameworkerrors "github.com/goravel/framework/errors"
"taskflow/app/enums"
"taskflow/app/facades"
+ "taskflow/app/mails"
"taskflow/app/models"
)
type ExportTasks struct {
}
func NewExportTasks(exportID uint) (*ExportTasks, []queue.Arg) {
return &ExportTasks{}, []queue.Arg{
{Type: "uint", Value: exportID},
}
}
// Signature The name and signature of the job.
func (receiver *ExportTasks) Signature() string {
return "export_tasks"
}
// Handle Execute the job.
func (receiver *ExportTasks) Handle(args ...any) error {
exportID, ok := args[0].(uint)
if !ok {
return errors.New("export tasks: the argument must be the export id")
}
var export models.Export
err := facades.Orm().Query().FindOrFail(&export, exportID)
if errors.Is(err, frameworkerrors.OrmRecordNotFound) {
return nil
}
if err != nil {
return err
}
var tasks []models.Task
if err := facades.Orm().Query().With("Assignee").OrderBy("id").Find(&tasks); err != nil {
return err
}
rows := [][]string{{"id", "title", "status", "assignee", "due_at"}}
for _, task := range tasks {
assignee, dueAt := "", ""
if task.Assignee != nil {
assignee = task.Assignee.Name
}
if task.DueAt != nil {
dueAt = task.DueAt.ToDateTimeString()
}
rows = append(rows, []string{strconv.Itoa(int(task.ID)), task.Title, string(task.Status), assignee, dueAt})
}
var file bytes.Buffer
if err := csv.NewWriter(&file).WriteAll(rows); err != nil {
return err
}
path := fmt.Sprintf("exports/%d.csv", export.ID)
if err := facades.Storage().Put(path, file.String()); err != nil {
return err
}
export.Status = enums.ExportDone
export.Path = path
+ if err := facades.Orm().Query().Save(&export); err != nil {
+ return err
+ }
+
+ var user models.User
+ if err := facades.Orm().Query().FindOrFail(&user, export.UserID); err != nil {
+ return err
+ }
- return facades.Orm().Query().Save(&export)
+ return facades.Mail().Queue(mails.NewExportReady(export, user))
}
// ShouldRetry gives a failed export two more tries, five seconds apart.
func (receiver *ExportTasks) ShouldRetry(err error, attempt int) (bool, time.Duration) {
return attempt < 3, 5 * time.Second
}
```
`Queue` renders the views right away and puts the finished email on the queue. The worker then sends it as a separate job, `goravel_send_mail_job`, so an SMTP failure fails only that job and leaves its own row in `failed_jobs`.
The export email needs its own Mailable and views, and they follow the assignment's:
```go title="app/mails/export_ready.go" collapse={1-7} collapse={18-24}
package mails
import (
"github.com/goravel/framework/contracts/mail"
"taskflow/app/models"
)
type ExportReady struct {
export models.Export
user models.User
}
func NewExportReady(export models.Export, user models.User) *ExportReady {
return &ExportReady{export: export, user: user}
}
func (receiver *ExportReady) Headers() map[string]string {
return map[string]string{}
}
func (receiver *ExportReady) Attachments() []string {
return []string{}
}
func (receiver *ExportReady) Content() *mail.Content {
return &mail.Content{
HtmlView: "export_ready.html",
TextView: "export_ready.txt",
With: map[string]any{
"Name": receiver.user.Name,
"Export": receiver.export,
},
}
}
func (receiver *ExportReady) Envelope() *mail.Envelope {
return &mail.Envelope{
To: []string{receiver.user.Email},
Subject: "Your task export is ready",
}
}
func (receiver *ExportReady) Queue() *mail.Queue {
return &mail.Queue{}
}
```
```html title="resources/mail/export_ready.html"
{% extends "layout.html" %}
{% block content %}
Hi {{ Name }},
Your export of all tasks is ready. Download it with GET /api/v1/exports/{{ Export.ID }}/download.
{% endblock %}
```
```text title="resources/mail/export_ready.txt"
{% autoescape off %}Hi {{ Name }},
Your export of all tasks is ready. Download it with GET /api/v1/exports/{{ Export.ID }}/download.{% endautoescape %}
```
An export now ends with an email to its owner:
```bash
$ curl -X POST http://localhost:3000/api/v1/exports -H "Authorization: Bearer $ANA"
{…,"id":1,"user_id":1,"status":"pending"}
$ sleep 2
$ curl http://localhost:8025/view/latest.txt
Hi Ana,
Your export of all tasks is ready. Download it with GET /api/v1/exports/1/download.
```
To check that, we stop Mailpit and ask for another export. The export still finishes, and only the email job fails:
```bash
$ docker compose stop mailpit
$ curl -X POST http://localhost:3000/api/v1/exports -H "Authorization: Bearer $ANA"
{…,"id":2,"user_id":1,"status":"pending"}
$ sleep 2
$ curl http://localhost:3000/api/v1/exports/2 -H "Authorization: Bearer $ANA"
{…,"id":2,"user_id":1,"status":"done"}
```
```text
2026-10-27 23:23:44.45 export_tasks ........................... 6.719ms DONE
2026-10-27 23:23:44.452 goravel_send_mail_job ...................... RUNNING
2026-10-27 23:23:44.454 goravel_send_mail_job ................. 1.049ms FAIL
```
The failed email waits in `failed_jobs`, with the connection error:
```bash
$ docker compose exec postgres psql -U goravel taskflow -At -c "select exception from failed_jobs"
dial tcp 127.0.0.1:1025: connect: connection refused
```
`queue:failed` lists the failed job with its uuid:
```bash
$ go run . artisan queue:failed
2026-10-27 23:23:45 aef68b21-e435-43ea-9f9e-b840b1e8cd37 ..... redis@default
```
Once Mailpit runs again, `queue:retry` puts that job back on the queue, and the worker sends the email:
```bash
$ docker compose start mailpit
$ go run . artisan queue:retry aef68b21-e435-43ea-9f9e-b840b1e8cd37
INFO Pushing failed queue jobs back onto the queue
aef68b21-e435-43ea-9f9e-b840b1e8cd37 .......................... 2.134ms DONE
$ curl http://localhost:8025/view/latest.txt
Hi Ana,
Your export of all tasks is ready. Download it with GET /api/v1/exports/2/download.
```
A queue of its own for mail:
`facades.Mail().Queue` puts the job on the default connection and queue. A Mailable picks others by returning them from its own `Queue` method, such as `&mail.Queue{Queue: "mail"}`. A queue other than the default needs a worker of its own, as in [Queues & Jobs](https://devchalk.com/goravel/16-queues-jobs-task-export#two-jobs-at-once).
## Tests
We test the emails in two ways:
- [`tests/unit/mails_test.go`](https://github.com/krishankumar01/goravel-taskflow/blob/part-17/tests/unit/mails_test.go) renders the assignment email through the pongo2 engine and sends nothing. It checks the recipient, the escaped HTML, the text part as typed and the due-date line.
- [`tests/feature/mail_test.go`](https://github.com/krishankumar01/goravel-taskflow/blob/part-17/tests/feature/mail_test.go) sends real email to Mailpit and reads it back through Mailpit's API, with the helpers in `tests/mailpit.go`. It checks that the assignee gets an email, that a manager who takes a task gets none, and that a finished export emails its owner.
The feature tests send real email, so they need Mailpit running, like Postgres and Redis:
```bash
$ go test ./tests/... --env=.env.testing
? taskflow/tests [no test files]
ok taskflow/tests/feature 2.020s
ok taskflow/tests/unit 0.461s
```
TaskFlow now emails the assignee of each assigned task and the owner of each finished export. All of them still go to Mailpit, because `MAIL_HOST` points there.
---
Previous: [16. Queues & Jobs](https://devchalk.com/goravel/16-queues-jobs-task-export.md)
Series: [Goravel](https://devchalk.com/goravel.md)