Middleware
Middleware is code that runs before and after a controller:
authentication, permission checks, logging, timing. In Winter a middleware is a
class that is also a PHP attribute, so it is applied declaratively:
#[AuthMiddleware] above a controller or a method.
What middleware is and why
Middleware is code that wraps request handling: it runs before the controller and after it.
The problem. Many jobs have nothing to do with a particular endpoint’s business logic yet are needed by many of them: check a token, confirm permissions, log the request, measure the time. Writing that into every controller produces copy-paste and duplicated cross-cutting logic.
The solution is to extract the wrapper into a reusable class and attach it
declaratively where it is needed. In Winter such a class is also a PHP
attribute, so attaching it is a single line above the controller:
#[AuthMiddleware].
Declaring a middleware
The generator writes the stub — it appends the Middleware suffix itself:
php call make -m .Auth # → main/AuthMiddleware.phpA full declaration looks like this. Every line matters — they are covered below:
<?php
namespace Main;
use Flytachi\Winter\DI\Attribute\Autowired;
use Flytachi\Winter\Kernel\Http\Contracts\HttpRequest;
use Flytachi\Winter\Kernel\Http\Contracts\HttpResponse;
use Flytachi\Winter\Kernel\Http\Stereotype\Middleware;
#[\Attribute(\Attribute::TARGET_CLASS | \Attribute::TARGET_METHOD)]
class AuthMiddleware extends Middleware
{
#[Autowired] private TokenService $tokens; // dependencies, as in a controller
public function __construct(private string $role = 'user') {}
public function before(HttpRequest $request, HttpResponse $response): void
{
// the check before the controller
}
public function after(mixed $result): mixed
{
return $result; // transforming the response
}
}What it is made of:
| Element | Required | What it gives |
|---|---|---|
extends Middleware |
yes | The stereotype the collector recognises |
#[\Attribute(...)] |
yes | Without it PHP refuses to use the class as an attribute |
TARGET_CLASS | TARGET_METHOD |
yes | Where it may be attached — controller, method or both |
| A constructor | no | Receives the arguments passed to the attribute |
#[Autowired] properties |
no | The instance is built by the container, injection works |
before() / after() |
no | Override only the one you need; the other stays a no-op |
The forgotten `#[\Attribute]` line
The most common mistake when writing middleware by hand: the class extends the stereotype but is not declared an attribute. PHP then refuses to apply it above a controller — the error appears while attributes are parsed, not when it is called. The generator adds that line for you.
The methods
The stereotype provides two hooks, both already implemented as empty — override the one you need.
before() — ahead of the controller
public function before(HttpRequest $request, HttpResponse $response): voidRuns before the handler is called. Token, permission and limit checks belong here. There is no return value: middleware either lets the request through or aborts it with an exception.
To abort a request, throw a MiddlewareException (401 by default) or any
ResponseException. The controller is never called and the exception becomes an
HTTP response:
use Flytachi\Winter\Base\HttpCode;
use Flytachi\Winter\Kernel\Http\Header;
use Flytachi\Winter\Kernel\Http\Middleware\MiddlewareException;
public function before(HttpRequest $request, HttpResponse $response): void
{
$token = Header::getBearerToken();
if ($token === null) {
throw new MiddlewareException('Token missing'); // 401
}
if (!$this->tokens->isValid($token)) {
MiddlewareException::throw('Forbidden', HttpCode::FORBIDDEN); // 403
}
}A header can be attached to the error response:
throw new MiddlewareException('Rate limited', HttpCode::TOO_MANY_REQUESTS)
->withHeader('Retry-After', '60');Abort with an exception, not `$response->end()`
$response does have an end() method, but calling it from before() is wrong:
the request does not stop there, control still reaches the controller, and the
response tries to go out twice. The only way to abort is to throw. Writing a header
with $response->header() is perfectly fine — it lands in the final response.
after() — behind the controller
public function after(mixed $result): mixedReceives what the handler returned, before serialisation into an HTTP response —
that is a ResponseEntity, an array or a string, exactly as the method returned it.
Whatever you return goes onwards down the chain.
public function after(mixed $result): mixed
{
// one envelope for every controller response
if ($result instanceof ResponseEntity) {
return $result->body([
'data' => $result->getBody(),
'meta' => ['duration' => $this->elapsed()],
]);
}
return $result;
}`after()` is not `finally`
If the handler threw, after() does not run: control goes straight to error
handling. The same applies when before() aborts — the after() of middleware that
already ran is not called either. So releasing resources, closing a transaction or
finalising a metric must not hang on after() — use finally inside the handler
for that.
Attachment settings
Everything that governs how a middleware is attached lives in two places: the flags
in the class’s own #[\Attribute(...)], and how the attribute is applied.
Where it goes
On a whole controller, on a single method, or on both — then they stack:
#[AuthMiddleware] // the whole controller
#[RequestMapping('admin')]
class AdminController extends Controller
{
#[GetMapping('info')]
public function info(): ResponseEntity { /* Auth only */ }
#[RateLimitMiddleware] // added for this method
#[GetMapping('stats')]
public function stats(): ResponseEntity { /* Auth + RateLimit */ }
}Class middleware always runs before method middleware — regardless of the order
they are written in. Within each group the declaration order is preserved:
before() runs top to bottom, after() in reverse, like nested wrappers:
AuthMiddleware::before() ← the class first
RateLimitMiddleware::before() ← then the method
the controller method
RateLimitMiddleware::after()
AuthMiddleware::after() ← and back outwardsArguments
Everything passed to the attribute reaches the middleware constructor. That lets one class serve different rules:
#[RoleMiddleware('admin')]
#[GetMapping('stats')]
public function stats(): ResponseEntity { /* ... */ }Applying it twice
By default the same middleware attaches to a target once. To apply it twice with
different arguments, give the class the IS_REPEATABLE flag:
#[\Attribute(\Attribute::TARGET_CLASS | \Attribute::TARGET_METHOD | \Attribute::IS_REPEATABLE)]
class RoleMiddleware extends Middleware { /* ... */ }
// now this is allowed:
#[RoleMiddleware('admin')]
#[RoleMiddleware('audit')]
#[GetMapping('stats')]
public function stats(): ResponseEntity { /* ... */ }What middleware cannot do
Three limits people most often trip over:
| Limit | What it means |
|---|---|
| Not inherited | Middleware on a base controller does not carry over to subclasses — the attribute is read from the class itself only |
| No global registration | There is no single line to attach middleware “to the whole application”; it is named on each controller |
| Does not run on 404 and 405 | Middleware is bound to a route handler, so when no path matches, no before() is called |
The last one has a practical consequence: cross-cutting logging or metrics for all requests cannot be built on middleware — those things live a level above.
Ready-made middleware
Three self-contained examples for common jobs. Each is a complete file: copy it, rename it for your project, attach it as an attribute.
Logging and timing
The simplest case, using both hooks. before() records the start, after()
computes the duration:
<?php
namespace Main;
use Flytachi\Winter\DI\Attribute\Autowired;
use Flytachi\Winter\Kernel\Http\Contracts\HttpRequest;
use Flytachi\Winter\Kernel\Http\Contracts\HttpResponse;
use Flytachi\Winter\Kernel\Http\Stereotype\Middleware;
use Psr\Log\LoggerInterface;
#[\Attribute(\Attribute::TARGET_CLASS | \Attribute::TARGET_METHOD)]
class TimingMiddleware extends Middleware
{
#[Autowired] private LoggerInterface $logger;
private float $startedAt;
public function before(HttpRequest $request, HttpResponse $response): void
{
$this->startedAt = microtime(true);
$this->logger->info('request', [
'method' => $request->getMethod(),
'uri' => $request->getUri(),
'ip' => $request->getClientIp(),
]);
$response->header('X-Request-Id', bin2hex(random_bytes(8)));
}
public function after(mixed $result): mixed
{
$ms = round((microtime(true) - $this->startedAt) * 1000, 1);
$this->logger->info('response', ['duration_ms' => $ms]);
return $result;
}
}State in a property ($startedAt) is safe here: a middleware instance is built
afresh per request, just like a controller.
Only on routes that matched
Such middleware will not see requests where no route matched: on 404 and 405
before() is not called. Accounting for genuinely every request needs a level
above.
One response envelope
An example using after() alone: bring controller responses to a common shape
without touching the handlers.
<?php
namespace Main;
use Flytachi\Winter\Kernel\Http\Response\ResponseEntity;
use Flytachi\Winter\Kernel\Http\Stereotype\Middleware;
#[\Attribute(\Attribute::TARGET_CLASS | \Attribute::TARGET_METHOD)]
class EnvelopeMiddleware extends Middleware
{
public function after(mixed $result): mixed
{
if ($result instanceof ResponseEntity) {
return $result->body([
'success' => true,
'data' => $result->getBody(),
]);
}
return ['success' => true, 'data' => $result];
}
}This will not extend to error responses — on an exception after() does not run. A
single error format is defined separately; see
Error handling.
Shipped with the framework
The core ships one middleware itself —
Flytachi\Winter\Kernel\Http\Middleware\ClientTimezoneMiddleware. It takes the
client’s timezone from the request and applies it for the duration of handling,
falling back to TIME_ZONE from .env when the client sent nothing:
use Flytachi\Winter\Kernel\Http\Middleware\ClientTimezoneMiddleware;
#[ClientTimezoneMiddleware]
#[RequestMapping('reports')]
class ReportController extends Controller { /* ... */ }Inside the handler read the zone through Timezone::current() — that value is
isolated per coroutine, so concurrent requests each have their own. More on the
Localization page.
Pattern: authentication and permissions
What follows is not another example but a full walkthrough of one technique. It solves a problem nearly every project runs into: work out who sent the request and make that available to everything handling it, without threading it through as arguments.
What you end up with. The controller stops knowing about headers and tokens — it simply asks who arrived and receives a user:
return ResponseEntity::created($this->service->create($this->auth->user()));What it is assembled from — three mandatory parts and one optional:
| Part | Role |
|---|---|
| The request context | An object living for one request; holds who arrived |
| The authentication middleware | Identifies the client and writes the result into the context |
| Controllers and services | Read the context, without knowing how it was filled |
| The permission middleware (optional) | Checks whether a particular action is allowed |
This is a template, not a finished solution
In the example, identification is done with JWT and permissions with a list of strings. Both are interchangeable parts. The token can be replaced by a session, a cookie or an API key; permissions by roles or policies: the contents of steps 2 and 4 change, the technique does not. What matters is the distribution of roles: only middleware writes into the context; everyone else reads.
Step 1. The request context
Start with the object the identification result will be stored in. It is an ordinary
class — only the #[Request] attribute above it matters.
<?php
namespace Main;
use Flytachi\Winter\DI\Attribute\Request;
#[Request]
class AuthContext
{
private ?User $user = null;
public function setUser(User $user): void { $this->user = $user; }
public function user(): User
{
return $this->user ?? throw new \LogicException('No authenticated user');
}
}Why #[Request]. The attribute tells the container: one instance per request.
Everyone asking for AuthContext within one request — middleware, controller,
service — receives the very same object, while concurrent requests each get
their own (under Swoole the isolation is per coroutine).
Without that attribute the class is transient: every consumer gets its own new
instance. The middleware writes the user into its own, the controller reads from its
own — and sees null. There is no error, just an empty context:
#[Request] middleware and controller → one object → user-42
no attribute middleware and controller → different objects → nullWhy user() throws instead of returning null. If execution reached the
context, the middleware ran and the user is there. An empty context at that moment
is not “an unauthenticated request” but a mistake in how the middleware was
attached, and it is better to hear about it at once.
Step 2. The middleware that fills the context
Now the part that identifies the client. Its only job is to parse the incoming data and put the result into the context; beyond that it takes no part in handling.
<?php
namespace Main;
use Flytachi\Jwt\Entity\PublicKey;
use Flytachi\Jwt\JWT;
use Flytachi\Jwt\JWTException;
use Flytachi\Winter\Base\HttpCode;
use Flytachi\Winter\DI\Attribute\Autowired;
use Flytachi\Winter\Kernel\Http\Contracts\HttpRequest;
use Flytachi\Winter\Kernel\Http\Contracts\HttpResponse;
use Flytachi\Winter\Kernel\Http\Header;
use Flytachi\Winter\Kernel\Http\Middleware\MiddlewareException;
use Flytachi\Winter\Kernel\Http\Stereotype\Middleware;
#[\Attribute(\Attribute::TARGET_CLASS | \Attribute::TARGET_METHOD)]
class JwtMiddleware extends Middleware
{
#[Autowired] private AuthContext $auth;
#[Autowired] private UserRepository $users;
public function before(HttpRequest $request, HttpResponse $response): void
{
$token = Header::getBearerToken();
if ($token === null) {
throw new MiddlewareException('Authorization token is missing'); // 401
}
try {
$payload = JWT::decode($token, [new PublicKey(env('JWT_SECRET'), 'HS256')]);
} catch (JWTException $e) {
throw new MiddlewareException($e->getMessage(), HttpCode::UNAUTHORIZED);
}
$this->auth->setUser($this->users->find($payload->getClaim('sub')));
}
}What happens, in order: take the token from the Authorization: Bearer header →
verify it → load the user → put them into the context. That last line is the
point of the whole step.
Note two decisions:
- Rejection is an exception.
MiddlewareExceptiongives401by default. Control never reaches the controller, and the exception becomes an HTTP response by itself. - The context arrives via
#[Autowired]. Middleware is built by the container, so injection works exactly as in a controller.
JWT here is replaceable
The jwt package is installed separately
(composer require flytachi/jwt), and JWT::decode() verifies the signature, the
algorithm and the expiry in one go — which is why a single catch suffices. But
that is not what matters for the pattern: replace the body of before() with a
session or API-key check, keep the last line, and everything else continues to work
unchanged.
Step 3. The controller reads the context
Attach the middleware to the controller and ask for the context with the same
#[Autowired]:
#[JwtMiddleware] // the whole controller behind authentication
#[RequestMapping('api/posts')]
class PostController extends Controller
{
#[Autowired] private AuthContext $auth;
#[Autowired] private PostService $service;
#[PostMapping]
public function create(#[RequestJson, Valid] PostRequest $req): ResponseEntity
{
return ResponseEntity::created(
$this->service->create($req, $this->auth->user())
);
}
}The controller does not parse headers, does not know about tokens and does not check
them: by the time the method is called the middleware has already run. The same
AuthContext can be asked for in a service too — the object is one per request, so
there is no need to thread it through the layers as an argument.
Step 4. Checking permissions
Authentication answers “who is this”, authorisation answers “is this allowed”. The second is conveniently a separate middleware, because each endpoint has its own requirement. The needed one is passed as an attribute argument.
First teach the context to answer a permission question:
public function can(string $permission): bool
{
return in_array($permission, $this->user?->permissions ?? [], true);
}Then the middleware that uses it:
<?php
namespace Main;
use Flytachi\Winter\Base\HttpCode;
use Flytachi\Winter\DI\Attribute\Autowired;
use Flytachi\Winter\Kernel\Http\Contracts\HttpRequest;
use Flytachi\Winter\Kernel\Http\Contracts\HttpResponse;
use Flytachi\Winter\Kernel\Http\Middleware\MiddlewareException;
use Flytachi\Winter\Kernel\Http\Stereotype\Middleware;
#[\Attribute(
\Attribute::TARGET_CLASS | \Attribute::TARGET_METHOD | \Attribute::IS_REPEATABLE
)]
class RequirePermission extends Middleware
{
#[Autowired] private AuthContext $auth;
public function __construct(private string $permission) {}
public function before(HttpRequest $request, HttpResponse $response): void
{
if (!$this->auth->can($this->permission)) {
MiddlewareException::throw(
"Permission '{$this->permission}' required",
HttpCode::FORBIDDEN, // 403
);
}
}
}Applied one permission per line; the IS_REPEATABLE flag allows the repetition:
#[JwtMiddleware] // on the class: who arrived
#[RequestMapping('api/posts')]
class PostController extends Controller
{
#[RequirePermission('post.edit')] // on the method: what they may do
#[RequirePermission('post.publish')]
#[PutMapping('{id:\d+}/publish')]
public function publish(#[PathVariable] int $id): ResponseEntity { /* ... */ }
}Why this order works. RequirePermission reads the user from the context, so it
must run after JwtMiddleware. And it does: class middleware always precedes
method middleware. That is why authentication goes on the class and permission
checks go on the methods.
A list of strings is the simplest option; replacing it with roles, policies or a
database query means rewriting only can() and the body of before(). Where it
attaches to endpoints does not change.
The rules the pattern rests on
Four conditions — break any of them and the technique stops working, usually silently:
| Rule | What happens otherwise |
|---|---|
The context carries #[Request] |
Everyone gets their own instance and the controller reads null |
| Only middleware writes to the context | Writes from several places, and it is no longer clear who put the value there |
| Rejection goes through an exception | $response->end() does not abort handling; the controller runs anyway |
| Authentication on the class, permissions on the methods | The permission check runs before identification and sees an empty context |
Next
- Controllers — what middleware wraps
- Error handling — what a thrown exception becomes
- Dependency injection — scopes and request scope
- CORS — cross-origin policy, a separate mechanism
- The jwt package — issuing and verifying tokens