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

5.0 KiB

title, description, section, order
title description section order
Request lifecycle How an HTTP request reaches a plugin handler: route collection, named middleware, constraints, body limits, CORS, recovery and request context values. architecture 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:

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 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, 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:

// 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.