Files
summercms/docs/architecture/request-lifecycle.md
Jakub Zych 1f8f5e1b51 feat(11.1-03): add the Architecture and Plugins docs sections
- architecture: introduction, Go modules and workspaces, application
  lifecycle, request lifecycle
- plugins: registration, scheduling, extending, testing
- verified Examples for backpack services, towel request context,
  pact schedules and festival events
- TestDocsRequiredPages lists the eight new pages
2026-09-30 22:12:08 +02:00

83 lines
5.0 KiB
Markdown

---
title: Request lifecycle
description: "How an HTTP request reaches a plugin handler: route collection, named middleware, constraints, body limits, CORS, recovery and request context values."
section: architecture
order: 40
---
# Request lifecycle
The `serve` command builds one `http.Handler` from every plugin's route declarations and serves it with the Go standard library. This page follows a request from the socket to your handler and back.
## Building the router
When `serve` starts, `surf.BuildRouter` collects the routes:
1. It registers the built-in middleware (`throttle`, `body.limit`, `locale.from-principal` and, when the admin is enabled, `backend`).
2. It registers the named middleware of every plugin that implements `pact.HasMiddleware`, `pact.HasMiddlewareFactories` or `pact.HasHouseMiddleware`, and the rate-limit buckets of every `surf.BucketProvider`.
3. It calls `pact.HasRoutes.Routes` on every plugin in activation order, passing a `pact.Router` that records groups, routes, middleware names and constraints.
4. It mounts the cabana admin API and checks every route.
`surf.Assemble` then compiles the routes onto a standard library `http.ServeMux`. A duplicate route, an unknown middleware name or a malformed `throttle` parameter fails here, at boot, and `serve` exits with the error instead of serving a broken router. `./bin/acme route:list` builds the same router without opening the database and prints the route table.
## Declaring routes
A plugin declares routes the way a WinterCMS `routes.php` file does, with groups that share a prefix and middleware:
```php
Route::group(['prefix' => 'api/blog', 'middleware' => ['auth']], function () {
Route::get('posts/{id}', 'Acme\Blog\Http\Posts@show')->where('id', '[0-9]+');
});
```
In Go the same declaration is a `pact.HasRoutes` method. Paths use Go `http.ServeMux` patterns, `pact.Router.Where` and `pact.Router.WhereIn` constrain the last declared route, and middleware names are strings that must be registered by the time the router is built. The [surf](../../modules/surf/README.md) reference has the full route builder with rate-limit buckets and per-route body limits.
## The wrapping order
surf wraps every non-raw route in the same layers. From the outside in:
1. CORS, only for the paths configured under `http.cors.paths`, including preflight requests.
2. Panic recovery. A panic becomes an opaque JSON 500 from [wire](../../modules/wire/README.md), and because the response is buffered until the handler returns, the client never receives half a body.
3. The request locale, taken from the `Accept-Language` header and stored in the context.
4. The body limit: `http.body_limits.default_bytes`, or the route's own `body.limit:<bytes>`.
5. The route's middleware, in the order you listed them: group middleware first, then the route's own.
6. The path constraints. A request whose parameter fails `pact.Router.Where` or `pact.Router.WhereIn` gets a 404 before your handler runs.
7. Your handler.
Raw groups, declared with `pact.Router.GroupRaw`, are for webhooks and file streams. They skip the default body limit, refuse house middleware, and a panic in them returns a bare 500.
## Request context values
WinterCMS reads the current locale and user through facades. SummerCMS carries them on the request's `context.Context`. surf stores the locale with `towel.WithLocale`, the authentication middleware stores the signed-in principal, and your own middleware can add the organization or collection with `towel.WithOrganization` and `towel.WithCollection`. Any code that receives the context reads them back without a global lookup:
```go src=modules/towel/example_test.go#ExampleWithLocale
// A middleware stores the organization once for the whole request.
withAcme := func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx := towel.WithOrganization(r.Context(), "acme")
next.ServeHTTP(w, r.WithContext(ctx))
})
}
// The handler reads the values back from its request context.
listPosts := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
org, _ := towel.Organization(r.Context())
locale, ok := towel.Locale(r.Context())
if !ok {
locale = "en"
}
fmt.Fprintf(w, "posts for %s in %s", org, locale)
})
// surf sets the locale from Accept-Language; here the test sets it.
req := httptest.NewRequest(http.MethodGet, "/api/blog/posts", nil)
req = req.WithContext(towel.WithLocale(req.Context(), "pl"))
rec := httptest.NewRecorder()
withAcme(listPosts).ServeHTTP(rec, req)
fmt.Println(rec.Body.String())
// Output: posts for acme in pl
```
## Writing responses
Handlers write JSON with `wire.WriteJSON`, which keeps bodies byte-compatible with a Laravel backend: HTML characters are not escaped and there is no trailing newline. `wire.Slice` turns a nil list into `[]`, `wire.Time` marshals timestamps in Carbon's `+00:00` form and `wire.TriBool` models a nullable boolean. Errors that should look like your API's error envelope go through the house middleware a plugin registers with `pact.HasHouseMiddleware`.