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.
CodeStart from the Installation & Project Architecture code
git clone https://github.com/krishankumar01/goravel-taskflowcd goravel-taskflowgit checkout part-01cp .env.example .envgo run . artisan key:generatedocker compose up -dHot reload with Air
Right now you have to restart the server after every change. Air watches the project and restarts it for you.
go install github.com/air-verse/air@latestair -v __ _ ___ / /\ | | | |_)/_/--\ |_| |_| \_v1.67.4, built with Go go1.27.3If go run . is still running, stop it with Ctrl+C. From now on, start the app with Air instead:
airAir 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:
[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, with a method per HTTP verb. The skeleton already has a few. The highlighted one returns the welcome page:
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:
19 collapsed lines
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:
curl -i http://localhost:3000/helloHTTP/1.1 200 OKContent-Type: text/plain; charset=utf-8
Hello, worldThe content type is text/plain. To send HTML instead, set the Content-Type header:
21 collapsed lines
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, "<h1>Hello, world</h1>") })}Open 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:
21 collapsed lines
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, "<h1>Hello, world</h1>") return ctx.Response().Json(200, map[string]any{ "message": "Hello, world", }) })}Reload /hello and you get:
{ "message": "Hello, world"}There are other response types too:
Filesends a file.Datasends raw bytes with a content type.Streamsends 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:
package routes
import ( "github.com/goravel/framework/contracts/http" "github.com/goravel/framework/contracts/route" "github.com/goravel/framework/support"
21 collapsed lines
"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 and curl both work. In this series I use the HTTP client built into GoLand, because the requests live in the project next to the code. Create requests.http in the project root:
### Create taskPOST http://localhost:3000/api/v1/tasks
### HelloGET http://localhost:3000/helloEach 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:
curl -X POST http://localhost:3000/api/v1/taskstask createdNamed routes
A route can have a name. Your code can then find the route by its name instead of its path:
30 collapsed lines
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:
22 collapsed lines
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")) })
6 collapsed lines
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:
{ "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:
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:
package routes
import ( "github.com/goravel/framework/contracts/http" "github.com/goravel/framework/contracts/route" "github.com/goravel/framework/support"
16 collapsed lines
"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/tasksstops working, and there is no error message. The route is gone, so the answer should be404 Not Found. The client gets200 OKwith an empty body instead, so it looks like the request worked. The only sign is the 404 in the server’s request log./helloalso 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:
12 collapsed lines
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()}api.go and send it again.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 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:
go run . artisan make:controller TaskController SUCCESS Controller created successfullyIt writes 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 Taskwritestask.gowith a typeTask, so end the name inController.
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:
16 collapsed lines
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:
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:
curl http://localhost:3000/hello{ "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.
MoreResource 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.
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:
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] routesIt 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.
git checkout part-02 See the changesBrowse the code ↗Download zip