--- title: Routing description: Declare a plugin's HTTP routes with groups, auth groups, path constraints and named middleware through pact.HasRoutes, and write JSON responses with wire. section: services order: 30 --- # Routing A WinterCMS plugin declares its routes in `routes.php` with `Route::group`, `->middleware()` and `->where()`. A SummerCMS plugin implements `pact.HasRoutes`: its `Routes` method receives a `pact.Router` with the same builder shape. [surf](../../modules/surf/README.md) collects every plugin's routes into one standard library `http.ServeMux`, and checks all of them when the application starts, so a duplicate route, an unknown middleware name or a malformed throttle stops the start-up instead of failing on the first request. Handlers are ordinary `http.HandlerFunc` values. There are no controllers to extend and no request objects to learn. ## Declaring routes This plugin declares public routes, an auth group, path constraints and per-route middleware: ```go src=modules/surf/example_test.go#BlogPlugin.Routes // Routes is the Go form of the plugin's routes.php. func (p *BlogPlugin) Routes(r pact.Router) error { r.Group("/api/blog", surf.Use("throttle:60,1"), func(g pact.Router) { g.Get("/posts/{id}", showPost) g.Where("id", `[0-9]+`) g.Get("/posts/{status}/list", listPosts) g.WhereIn("status", "draft", "published") // An auth group: every route inside needs a signed-in user. g.Group("", surf.Use("acme.auth"), func(auth pact.Router) { auth.Get("/me", showMe) auth.Post("/posts/{id}/comments", addComment, "throttle:blog.comments", "body.limit:65536") }) }) return nil } ``` - `pact.Router.Group` adds a path prefix and a middleware list to the routes declared inside it; groups nest. `surf.Use` builds the list. - `pact.Router.Get`, `pact.Router.Post`, `pact.Router.Put`, `pact.Router.Patch` and `pact.Router.Delete` take a path in Go's pattern syntax (`/posts/{id}`) and optional middleware names for that route alone. - `pact.Router.Where` restricts a path parameter of the route declared just before it to a regular expression matched against the whole segment, and `pact.Router.WhereIn` to a list of values. A request that fails a constraint gets a 404. In a handler, `r.PathValue("status")` reads a parameter, and `surf.IntParam` reads one as a positive integer: ```go src=modules/surf/example_test.go#showPost func showPost(w http.ResponseWriter, r *http.Request) { id, ok := surf.IntParam(r, "id") if !ok { http.NotFound(w, r) return } wire.WriteJSON(w, http.StatusOK, map[string]any{"id": id}) } ``` ## Auth groups An auth group is a group whose middleware list names a guard. The plugin above turns a [bouncer](../../modules/bouncer/README.md) JWT guard into named middleware and returns it from `pact.HasMiddleware`: ```go src=modules/surf/example_test.go#BlogPlugin.Middlewares // Middlewares registers the plugin's named middleware: here, a JWT guard // that answers 401 when the request has no valid token. func (p *BlogPlugin) Middlewares() map[string]pact.Middleware { guards := bouncer.NewRegistry() guard := bouncer.NewJWTGuard(secret, users{}, bouncer.NewMemoryBlacklist()) if err := guards.Register(p.ID(), "acme.auth", guard); err != nil { panic(err) } auth, err := guards.Middleware("acme.auth") if err != nil { panic(err) } return map[string]pact.Middleware{"acme.auth": auth} } ``` Every route in the group then requires a valid token, and handlers read the signed-in user with `bouncer.User`. The guard answers 401 with a JSON body when the token is missing or invalid. See [Authentication](authentication.md) for guards and tokens. The admin API uses the built-in `backend` middleware name, which the framework registers when the admin is enabled. ## Middleware Named middleware is any `func(http.Handler) http.Handler` a plugin returns from `pact.HasMiddleware`. A plugin that needs a parameter, used as `name:param`, returns a factory from `pact.HasMiddlewareFactories`. Middleware names are global, so prefix them with the plugin: `acme.auth`, `blog.no-store`. A duplicate name fails the start-up. The framework registers these names: | Name | Does | |------|------| | `throttle:` or `throttle:,` | Rate limiting; see [Rate limiting](rate-limiting.md). | | `body.limit:` | Replaces the default request body limit for the route. | | `locale.from-principal` | Switches the request locale to the signed-in user's preferred locale. | | `backend` | The admin guard, when the admin is enabled. | Every route also gets, around its own middleware, JSON panic recovery, the request locale from `Accept-Language`, the body limit from `http.body_limits.default_bytes`, and CORS headers when its path matches `http.cors.paths`. The order is described in [Request lifecycle](../architecture/request-lifecycle.md). `pact.Router.GroupRaw` declares a raw group for routes that must not be wrapped in the house JSON middleware, such as webhooks, file streams or the OAuth endpoints: the default body limit is skipped, and a panic returns a bare 500. ## Responses Write JSON with `wire.WriteJSON`. It produces what PHP's `json_encode` produces: HTML characters are not escaped and there is no trailing newline. `wire.Time` marshals a timestamp as Carbon does (`+00:00`, never `Z`), `wire.TriBool` is a nullable boolean, and `wire.Slice` turns a nil slice into `[]`: ```go src=modules/wire/example_test.go#ExampleWriteJSON var tags []string // nil: the post has no tags warsaw := time.FixedZone("CEST", 2*60*60) body := postJSON{ ID: 1, Title: "Tips & ", Tags: wire.Slice(tags), Featured: wire.TriBool{}, Pinned: wire.TriBool{Value: true, Valid: true}, PublishedAt: wire.Time{Time: time.Date(2026, 9, 30, 14, 5, 0, 0, warsaw)}, } rec := httptest.NewRecorder() wire.WriteJSON(rec, http.StatusOK, map[string]any{"data": body}) fmt.Println(rec.Code, rec.Header().Get("Content-Type")) fmt.Printf("%s|\n", rec.Body.String()) rec = httptest.NewRecorder() wire.WriteOpaque500(rec) fmt.Println(rec.Code, rec.Body.String()) // Output: // 200 application/json // {"data":{"id":1,"title":"Tips & ","tags":[],"featured":null,"pinned":true,"published_at":"2026-09-30T12:05:00+00:00"}}| // 500 {"error":true,"message":"Internal server error"} ``` `wire.WriteOpaque500` writes the fixed 500 body that panic recovery also uses; it reveals nothing about the failure. ## Testing and listing routes `surf.Assemble` builds the complete handler from the application and its plugins, so a test can drive it with `net/http/httptest`: ```go src=modules/surf/example_test.go#ExampleAssemble // The application passes its config; http.body_limits is required there. app := backpack.New(nil) plugin := &BlogPlugin{} if err := plugin.Register(app); err != nil { // the runtime calls Register fmt.Println(err) return } h, err := surf.Assemble(app, []party.Plugin{plugin}) if err != nil { fmt.Println(err) return } token, _, _ := bouncer.Mint(secret, "42", "http://127.0.0.1:8080/api/login", time.Hour) do := func(method, path string, auth bool) { req := httptest.NewRequest(method, path, strings.NewReader("{}")) if auth { req.Header.Set("Authorization", "Bearer "+token) } rec := httptest.NewRecorder() h.ServeHTTP(rec, req) fmt.Println(method, path, rec.Code, strings.TrimSpace(rec.Body.String())) } do("GET", "/api/blog/posts/7", false) do("GET", "/api/blog/posts/seven", false) do("GET", "/api/blog/posts/draft/list", false) do("GET", "/api/blog/posts/deleted/list", false) do("GET", "/api/blog/me", false) do("GET", "/api/blog/me", true) do("POST", "/api/blog/posts/7/comments", true) do("POST", "/api/blog/posts/7/comments", true) // Output: // GET /api/blog/posts/7 200 {"id":7} // GET /api/blog/posts/seven 404 404 page not found // GET /api/blog/posts/draft/list 200 {"data":[],"status":"draft"} // GET /api/blog/posts/deleted/list 404 404 page not found // GET /api/blog/me 401 {"error":true,"message":"Token not provided"} // GET /api/blog/me 200 {"id":42} // POST /api/blog/posts/7/comments 201 {"created":true} // POST /api/blog/posts/7/comments 429 {"message":"Too Many Attempts."} ``` `route:list` builds the router the way `serve` does, without opening the database or listening, and prints every route with its plugin and middleware: ```sh ./bin/acme route:list ``` `surf.BuildRouter` returns the same information to Go code through `surf.Router.Routes`: ```go src=modules/surf/example_test.go#ExampleBuildRouter r, err := surf.BuildRouter(backpack.New(nil), []party.Plugin{&BlogPlugin{}}) if err != nil { fmt.Println(err) return } for _, rt := range r.Routes() { fmt.Println(rt.Method, rt.Pattern, rt.PluginID, rt.Middleware) } // Output: // GET /api/blog/posts/{id} acme.blog [throttle:60,1] // GET /api/blog/posts/{status}/list acme.blog [throttle:60,1] // GET /api/blog/me acme.blog [throttle:60,1 acme.auth] // POST /api/blog/posts/{id}/comments acme.blog [throttle:60,1 acme.auth throttle:blog.comments body.limit:65536] ``` ## CORS CORS is configured with the keys of Laravel's `config/cors.php`, under `http.cors`, and applies only to paths that match `http.cors.paths`. With no `http.cors` section, no CORS headers are sent: ```yaml cors: paths: ["api/*"] allowed_origins: ["https://blog.example.com"] allowed_methods: ["*"] allowed_headers: ["*"] supports_credentials: true ``` This fragment belongs in `config/http.yaml`. List the frontend's exact origin; `*` is for public, credential-free APIs only.