Web basics

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.

Base Http\Stereotype\MiddlewareHooks before() / after()Applied as an attribute on a class or 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:

bash
php call make -m .Auth   # → main/AuthMiddleware.php

A full declaration looks like this. Every line matters — they are covered below:

main/AuthMiddleware.php
<?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

php
public function before(HttpRequest $request, HttpResponse $response): void

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

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

php
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

php
public function after(mixed $result): mixed

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

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

php
#[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:

text
AuthMiddleware::before()        ← the class first
RateLimitMiddleware::before() ← then the method
  the controller method
RateLimitMiddleware::after()
AuthMiddleware::after()         ← and back outwards

Arguments

Everything passed to the attribute reaches the middleware constructor. That lets one class serve different rules:

php
#[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:

php
#[\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:

main/TimingMiddleware.php
<?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.

main/EnvelopeMiddleware.php
<?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:

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

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

main/AuthContext.php
<?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:

text
#[Request]        middleware and controller → one object → user-42
no attribute      middleware and controller → different objects → null

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

main/JwtMiddleware.php
<?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. MiddlewareException gives 401 by 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]:

main/PostController.php
#[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:

main/AuthContext.php
public function can(string $permission): bool
{
  return in_array($permission, $this->user?->permissions ?? [], true);
}

Then the middleware that uses it:

main/RequirePermission.php
<?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:

php
#[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