Core Concepts
Middleware Pipeline
Wheels middleware is a chain of functions that wrap every request before a controller is ever instantiated. Knowing what the chain does, what ships in it, and how to extend it lets you put cross-cutting concerns — tracing, CORS, security headers, rate limiting, auth — in the one place they belong.
You’ll learn:
- How the pipeline is shaped and what each link can do
- Where to register middleware globally and per route
- What the four built-ins cover out of the box
- When to reach for your own middleware instead of a controller filter
The pipeline model
Section titled “The pipeline model”Middleware is a chain of (request, next) functions. Each link inspects the incoming request struct, optionally calls next(request) to hand off to the next link, and optionally mutates the response on the way back. The last link is the framework’s core handler — the piece that instantiates your controller and runs the action. Order of registration is order of execution: first registered sees the request first and sees the response last.
Because the chain runs at dispatch, before any controller exists, middleware can short-circuit cheaply. A rate-limit rejection, a CORS preflight, a blocked auth attempt — each returns a response without ever paying the cost of constructing a controller, loading its filters, or hitting your action. That is the whole reason this layer exists separately from controller filters.
Where middleware registers
Section titled “Where middleware registers”Global middleware lives in config/settings.cfm as an ordered array. The order of the array is the pipeline.
set(middleware = [ new wheels.middleware.RequestId(), new wheels.middleware.SecurityHeaders(), new wheels.middleware.Cors(allowOrigins="https://myapp.com"), new wheels.middleware.RateLimiter(maxRequests=100, windowSeconds=60)]);RequestId wraps everything, so the correlation ID is set before any other middleware runs and the X-Request-ID header is the last thing stamped on the way out.
Built-in middleware
Section titled “Built-in middleware”Four components ship in wheels.middleware:
RequestId— stamps a unique correlation ID on every request. Setsrequest.wheels.requestIdfor downstream code and returns anX-Request-Idresponse header for log correlation.SecurityHeaders— applies OWASP response headers:X-Frame-Options,X-Content-Type-Options,X-XSS-Protection,Referrer-Policy, and optionallyContent-Security-Policy,Strict-Transport-Security, andPermissions-Policy. HSTS auto-enables in production when left unset.Cors— handles theOPTIONSpreflight and setsAccess-Control-*response headers. Requires an explicitallowOrigins— the default is empty so a missing configuration fails closed rather than opens the app to every origin.RateLimiter— throttles requests per client using fixed-window, sliding-window, or token-bucket strategies. Backed by in-memory counters by default; swap tostorage="database"for shared state across nodes.
Route-scoped middleware
Section titled “Route-scoped middleware”Middleware can also attach to a subtree of routes rather than the whole app. You declare it inside config/routes.cfm via .scope(... middleware=[...]), and it runs after the global chain only for routes inside that scope.
mapper() .scope(path="/api", middleware=["app.middleware.ApiAuth"]) .resources("users") .end().end();That pattern is the right fit for an API auth check that shouldn’t touch marketing pages, a tenant resolver that only applies under /app, or an admin guard limited to /admin resources. Nested scopes inherit their parent’s middleware, so an /api/v2 scope inside /api still sees ApiAuth.
Singleton lifecycle. Whether you register middleware by instance or by string CFC path, the dispatch layer resolves each entry once and reuses it for every request. String paths are instantiated on first match and cached in application scope keyed by the component path; the cache is cleared on every hard reload alongside application.wheels.*. This means stateful route-scoped middleware — an in-memory RateLimiter tracking per-client counts under /api, for example — correctly accumulates state across requests, matching the contract global middleware has always had. The corollary: every middleware component must be safe to share across concurrent requests. All built-in components already satisfy this.
Writing your own
Section titled “Writing your own”Implement wheels.middleware.MiddlewareInterface — a single handle(request, next) method returning the response string — and drop the component into app/middleware/. Reference it either by instance in config/settings.cfm or by CFC path in a route scope. That is the whole contract: inspect the request, decide whether to call next(request), decide what to do with the response when it comes back.
Reach for middleware when the concern is cross-cutting (every request, or every request in a subtree) and independent of your domain. Reach for a controller filter when the concern is bound to a specific controller’s lifecycle — loading @user before a show action, say. The two tools layer cleanly; middleware runs first, filters run once the controller exists.
See also
Section titled “See also”- The Request Lifecycle — where in the pipeline middleware sits
- How Routing Works — route-scoped middleware comes from here
- Rate Limiting — built-in rate limiter in depth (Phase 2b target)
- CORS — CORS configuration deep-dive (Phase 2b target)