Middleware wraps a request handler. It runs before the handler sees the
request and again after the handler returns a response, so it's the
place to put cross-cutting work - auth, logging, CORS, throttling,
timing, transforming the request or response. Suprnova's surface is the
same one Laravel users already know: a handle(request, next) method
that decides whether to forward the request, short-circuit it, or
mutate the response on the way back out.
The trait
A middleware is a struct that implements Middleware:
use ;
;
handle has three things to do, and you only have to do one of them
on any given request:
- Forward. Call
next(request).awaitto pass control to the next layer. The returnedResponseis what every layer above will see. - Short-circuit. Return
Err(HttpResponse::...)without callingnext. The framework collapses both arms ofResponse(Result<HttpResponse, HttpResponse>) into a single response - anErris a response, not a crash. See Error Model. - Mutate. Modify the request before forwarding, or modify the response after.
Next is Arc<dyn Fn(Request) -> MiddlewareFuture + Send + Sync> -
treat it like an async function from Request to Response.
Generating a stub
The CLI scaffolds a working middleware file:
The generated file isn't a TODO stub - it's a real middleware that
times the wrapped request and logs the inbound/outbound events with
the per-request id installed by RequestIdMiddleware. Replace the
body with whatever you actually need.
Registering middleware
Three places to install it, depending on scope:
Global
Runs on every request, in registration order. Use the
global_middleware! macro inside bootstrap():
// src/bootstrap.rs
use ;
use cratemiddleware;
pub async
global_middleware!(M) expands to register_global_middleware(M).
Registration is idempotent per concrete type - registering the same
struct twice keeps the first registration and emits a debug log. That
makes re-running boot (tests, hot-reload, multiple Server instances
in a process) safe. To install several copies of the same behaviour
with different config, wrap each in a distinct newtype.
Per route
Chain .middleware(M) on a route definition from the routes! macro:
// src/routes.rs
use ;
use crate::;
routes!
Per group
Apply middleware to every route in a group(...) block:
use Router;
use crate;
use crate;
new
// Public routes - no middleware.
.get
.get
// Every route under /api carries ApiMiddleware.
.group
.middleware
// Admin routes share auth.
.group
.middleware;
Execution order
At runtime the chain runs outside-in:
Request → RequestId → globals → group MW → route MW → handler
│
Response ← RequestId ← globals ← group MW ← route MW ← handler
The first middleware added runs first. On the way back out, the order
reverses - MiddlewareChain::execute nests each layer's post-processing
inside the previous one.
If a middleware short-circuits with Err(response), the chain unwinds
immediately: every layer ABOVE the short-circuit still sees the response
on the way back out, but layers BELOW (closer to the handler) do not run.
Group middleware is flattened, not stacked
This one matters and is worth calling out. Route-group middleware is
not a separate runtime layer. When GroupBuilder::try_finalize runs,
it copies the group's middleware into each grouped route's
(method, pattern) middleware list. By execution time, group middleware
is indistinguishable from middleware attached directly to the route.
Two consequences:
- Runtime ordering is still correct (group middleware runs before route middleware because it's registered first), but introspection cannot tell group from route middleware apart.
- Middleware keyed by the matched pattern (
"/posts/{id}"), not the raw path (/posts/42), so group middleware on parameterised routes fires reliably.
See framework/src/routing/group.rs for the flattening pass and
framework/src/middleware/chain.rs for the execution loop.
Short-circuiting
Return early to block a request before it reaches the handler:
use ;
;
The chain collapses Result<HttpResponse, HttpResponse> to a single
response, so Err(...) is just a response with a different role. The
layers above this middleware still observe it on the way out and can
post-process it.
Panic safety
MiddlewareChain::execute does NOT catch panics - a panic in any
middleware or in the handler unwinds straight out, like any other async
function. The request-path safety net lives one level up at the server
boundary in execute_chain_safely, which wraps the chain in
catch_unwind and converts a panic into a sanitised 500 with the
request id, dispatching ErrorOccurred for any observability listener.
See Request Lifecycle for the full panic-recovery flow.
This split is deliberate: standardised panic handling happens exactly
once, where the request lifecycle owns it, rather than being duplicated
inside the layer-agnostic primitive. A consumer driving a chain outside
that boundary is responsible for its own catch_unwind.
Built-in middleware
A non-exhaustive map. Each ships ready to install - most need a config struct, none need scaffolding.
| Middleware | Purpose |
|---|---|
RequestIdMiddleware |
Always-outermost layer; assigns a UUID per request and tags it through logs + X-Request-Id |
TimeoutMiddleware |
Bounds time-to-response; returns 503 when exceeded (see below) |
CorsMiddleware |
Handles CORS preflight + decorates cross-origin responses (see below) |
CsrfMiddleware |
Cookie-double-submit CSRF protection with configurable OriginPolicy |
RateLimitMiddleware / ThrottleRequestsMiddleware |
Token-bucket and sliding-window throttling; see Rate Limiting |
SessionMiddleware |
Loads/persists the session over cookies; powers req.session() |
AuthMiddleware / GuestMiddleware / BearerTokenMiddleware |
Guard membership checks; see Authentication |
LoginThrottleMiddleware / EnsureEmailVerifiedMiddleware / TwoFactorChallengeMiddleware |
Auth-flow gates; see Auth Flows |
MaintenanceMiddleware |
Returns 503 when the cache or filesystem maintenance flag is set |
InertiaVersionMiddleware / EncryptHistoryMiddleware |
Inertia asset-version negotiation + history encryption |
IncludeMiddleware |
Per-field include sets for #[derive(Data)] partial reloads |
Request timeouts
TimeoutMiddleware bounds how long a handler may take to produce a
response. A slow handler or a hung database query can otherwise hold
a connection open indefinitely; the timeout returns
503 Service Unavailable once the deadline is exceeded.
// src/bootstrap.rs - 30-second ceiling on every HTTP route.
use ;
global_middleware!; // DEFAULT_TIMEOUT = 30s
// Tighten a single endpoint to 5 seconds.
use ;
new
.get
.middleware;
TimeoutMiddleware::new(Duration) accepts any duration;
TimeoutMiddleware::seconds(n) is shorthand for whole seconds.
Global middleware runs outside route middleware, so a global timeout is an outer ceiling and a per-route timeout can only make a specific route stricter - the shorter deadline fires first. To let one route run longer than the global default, raise the global value or scope the global middleware to a route group that excludes that endpoint.
Streaming responses (HttpResponse::sse(...),
HttpResponse::stream_bytes(...)) are naturally exempt: the handler
returns immediately with a lazy body that hyper drains after the
middleware chain completes. WebSocket upgrades are also skipped
explicitly. See Timeouts for cancel-safety semantics.
CORS
CorsMiddleware adds the Access-Control-* headers a browser needs to
let a cross-origin page read your responses, and answers the preflight
OPTIONS request browsers send before non-simple cross-origin calls.
Same-origin apps (the default Inertia setup) don't need it - it matters
only when a browser on a different origin calls your API.
CORS must be installed globally so preflights reach it (a preflight never matches a route, so a per-route CORS middleware would never see one). There is intentionally no permissive default - pick an origin policy explicitly:
// src/bootstrap.rs
use ;
global_middleware!;
CorsConfig::any_origin() opts into Access-Control-Allow-Origin: *
explicitly. Builder methods: .methods([...]), .allow_headers([...])
/ .allow_any_headers(), .expose_headers([...]), .paths([...])
(scope CORS to URL patterns), .allow_origin_patterns([regex...]),
.skip_when(|req| bool), .allow_credentials(bool),
.max_age(Duration). Laravel-named aliases ship alongside (e.g.
.supports_credentials, .allowed_methods) so a Laravel config maps
directly.
Access-Control-Allow-Origin: * is invalid together with credentials -
the browser rejects it. When .allow_credentials(true) is set, the
middleware always echoes the specific request Origin instead of *,
so the invalid combination can never be emitted. Non-wildcard responses
also get Vary: Origin so shared caches stay correct. See
CORS.
Pipeline - Laravel's Illuminate\Pipeline\Pipeline
Pipeline is the Suprnova analogue of Laravel's pipeline class - a
fluent builder over MiddlewareChain that mirrors the send / through / pipe / then / then_return / finally_with shape Laravel users already
know. Useful when you want to assemble a middleware chain outside the
request lifecycle (a job, a CLI command, a one-off integration test):
use ;
let response = new
.send
.through
.pipe
.finally_with
.then
.await;
Rust-side aliases ship alongside the Laravel names: with_request for
send, with_middleware for through, push for pipe, on_finally
for finally_with, execute for then. Use whichever reads better in
your codebase.
| Pipeline method | Laravel | Rust alias | Purpose |
|---|---|---|---|
send(request) |
send($passable) |
with_request(request) |
Set the request being threaded through |
through(iter) |
through($pipes) |
with_middleware(iter) |
Replace the pipe list |
through_boxed(iter) |
- | - | Replace the pipe list with pre-boxed middleware |
pipe(M) |
pipe($pipes) |
push(M) |
Append a single middleware |
pipe_boxed(M) |
- | - | Append a pre-boxed middleware |
then(destination) |
then($destination) |
execute(destination) |
Run the chain with the destination handler |
then_with(req, dst) |
- | - | Override the passable inline |
then_return() |
thenReturn() |
- | Run the chain, return a 204 No Content |
finally_with(F) |
finally($callback) |
on_finally(F) |
Run after the destination resolves |
Terminable middleware - post-response hooks
Terminable middleware runs after the response has been sent to the client. Use it for slow IO that doesn't need to block the response: session persistence, audit logging, metrics flushes.
Suprnova ships this as a dedicated Terminable trait separate from
Middleware, so the request-path and termination-path stay clearly
typed. A type can implement one, the other, or both:
use ;
;
// In bootstrap.rs
register_terminable;
The server iterates registered terminables in registration order after
every response (4xx and 5xx included) and awaits each one. Errors are
logged via tracing::error! and swallowed - the response has already
left the building, so there's nobody left to surface them to.
Registration is idempotent per concrete type. registered_terminables(),
terminable_count(), and has_terminable::<T>() provide introspection
for tests and boot-time diagnostics.
Named aliases and groups
For consumers that prefer string-keyed middleware (Laravel's
middlewareAliases / middlewareGroups), Suprnova ships a
process-global alias + group registry:
use ;
// Aliases are factory closures - invoked fresh per resolution, so each
// route registration produces an independent middleware instance.
register_middleware_alias;
register_middleware_alias;
// Groups bundle aliases. Nested groups are supported.
register_middleware_group;
register_middleware_group;
// Resolve into a Vec<BoxedMiddleware> at boot or per-route.
let api_mws = resolve_middleware_group?;
resolve_middleware_group returns Err(MiddlewareResolveError) on:
UnknownGroup(name)- the named group was never registered;UnknownAlias { group, missing }- a group entry isn't a known alias;UnknownNestedGroup { group, missing }- a nested group reference fails to resolve;CycleDetected { group }- the group definition is recursive.
Registration of an alias or group is last-wins for the same name, mirroring Laravel's reassignable kernel array.
Middleware priority
prepend_middleware_priority::<M>() / append_middleware_priority::<M>()
register a TypeId in the process-global priority list - the Suprnova
analogue of Laravel's Kernel::$middlewarePriority. Middleware whose
type appears earlier in the list sorts to the front of the chain
regardless of registration order:
use ;
// SessionMiddleware always runs before AuthMiddleware regardless of
// the order they were registered.
;
;
middleware_priority() returns a snapshot of the current Vec<TypeId>
for diagnostics or for an embedder that wants to drive its own sorter.
Registry introspection
Beyond register_global_middleware, the registry exposes:
| Surface | Laravel | Purpose |
|---|---|---|
prepend_global_middleware(M) |
prependMiddleware |
Insert at the front of the chain |
has_global_middleware::<M>() |
hasMiddleware |
Whether type M is registered |
global_middleware_count() |
- | Number of globals currently registered |
MiddlewareRegistry::from_global() |
- | Snapshot the global registry into a per-server registry |
MiddlewareRegistry::prepend(M) |
- | Builder-style prepend on a registry instance |
MiddlewareRegistry::append_boxed(M) |
- | Append a pre-boxed middleware |
MiddlewareRegistry::prepend_boxed(M) |
- | Prepend a pre-boxed middleware |
MiddlewareRegistry::len() / is_empty() |
- | Builder introspection |
MiddlewareRegistry::from_global() snapshots the global registry at
call time. Register every global middleware BEFORE constructing the
server - a global_middleware! call made AFTER the server is built
does not retroactively apply, so a running server's middleware stack
cannot shift underneath it.
File layout
A typical layout once you have a few middlewares:
src/
├── middleware/
│ ├── mod.rs # mod + pub use
│ ├── auth.rs # AuthMiddleware
│ ├── logging.rs # LoggingMiddleware
│ └── audit.rs # AuditLogTerminator
├── bootstrap.rs # global_middleware! + register_terminable
├── routes.rs # .middleware(M) per-route
└── main.rs
make:middleware keeps src/middleware/mod.rs in sync - it appends
the new mod foo; declaration and the matching pub use foo::FooMiddleware;
re-export when the file is generated.
Why Suprnova diverges
Laravel registers middleware classes in app/Http/Kernel.php and
resolves them through the container, which performs reflection on
constructor type-hints to inject dependencies. PHP's request-per-process
model means the kernel is rebuilt every request, so the cost of
reflective resolution is paid once per request and disappears between
requests.
Suprnova's process model is one binary serving many concurrent requests
across many threads. Building a fresh chain per request would force a
synchronisation point on the global middleware list and re-allocate
Arc<dyn Middleware> for every layer on every request. Instead:
- Global middleware is registered into a
OnceLock<RwLock<Vec<...>>>at boot, keyed byTypeIdfor idempotent registration. MiddlewareRegistry::from_global()snapshots the global list once at server construction; the per-request chain reuses that snapshot.- The chain itself is composed by nesting
Arc<dyn Fn>closures, so per-request work is oneArc::cloneper layer rather than a fresh allocation.
The user-facing surface - handle(request, next), the global_middleware!
macro, named aliases, priority lists, terminable hooks - is the same
one a Laravel developer reaches for. The machinery underneath swaps
PHP's per-request rebuild for a Rust-shaped snapshot-at-boot model so
the framework can serve concurrent requests without contending on the
registry.
Next
- Request Lifecycle - where the chain runs and how panics are caught at the server boundary
- Error Model - what
Result<HttpResponse, HttpResponse>actually means and how short-circuits collapse - Timeouts -
TimeoutMiddlewarecancel-safety in detail - CORS - preflight handling, origin patterns, path scoping
- Rate Limiting -
RateLimitMiddleware/ThrottleRequestsMiddlewareandBackendErrorPolicy - Routing - what
routes!,Router, andgroup(...)expand into
