Skip to content

HTTP Interceptors: Pre- and Post-Processing in Winter Boot

Before a request reaches a REST controller method — and again after the method returns — Winter Boot passes it through a chain of interceptors. Interceptors are the right place for cross-cutting concerns such as authentication, authorization, rate limiting, audit logging, and adding response headers. They only run for requests that match a #[RestController] route.

Winter Boot has two kinds of interceptor; pick the one that matches the scope you need.

ControllerInterceptor

Scoped to a single controller class. Implement it directly on your controller. No registration required.

HandlerInterceptor

Scoped to the whole application. Registered in a WebMvcConfigurer bean and matched to requests by URI regex.

ControllerInterceptor (namespace dev\winterframework\core\web\ControllerInterceptor) is applied by having your controller itself implement the interface. Every request routed to that controller passes through its preHandle and postHandle methods, regardless of which handler method is invoked.

interface ControllerInterceptor
{
public function preHandle(
HttpRequest $request,
ResponseEntity $response,
ReflectionMethod $handler
): bool;
public function postHandle(
HttpRequest $request,
ResponseEntity $response,
ReflectionMethod $handler
): void;
}

The $handler argument is the ReflectionMethod instance for the specific handler method that is about to be (or has just been) called — useful when you need to inspect attributes or method-level metadata.

Method Called when Return value
preHandle() Before the handler method executes true → continue; false → abort
postHandle() After the handler method executes successfully N/A — return type is void

Example: Per-Controller Authentication Guard

Section titled “Example: Per-Controller Authentication Guard”
OrderController.php
<?php
namespace dev\winterboot\samples\controller;
use dev\winterframework\core\web\ControllerInterceptor;
use dev\winterframework\stereotype\Autowired;
use dev\winterframework\stereotype\RestController;
use dev\winterframework\stereotype\web\GetMapping;
use dev\winterframework\web\http\HttpRequest;
use dev\winterframework\web\http\ResponseEntity;
use ReflectionMethod;
#[RestController]
class OrderController implements ControllerInterceptor
{
#[Autowired]
protected AuthService $authService;
// --- ControllerInterceptor ---
public function preHandle(
HttpRequest $request,
ResponseEntity $response,
ReflectionMethod $handler
): bool {
$token = $request->getFirstHeader('Authorization');
if (!$this->authService->isValid($token)) {
$response->withStatus(\dev\winterframework\web\http\HttpStatus::$UNAUTHORIZED)
->withJson(['error' => 'Unauthorized']);
return false; // stop processing — no controller method is called
}
return true; // continue to the handler method
}
public function postHandle(
HttpRequest $request,
ResponseEntity $response,
ReflectionMethod $handler
): void {
// Runs after the handler method; useful for response decoration
}
// --- Handler methods ---
#[GetMapping(path: '/orders')]
public function listOrders(): array
{
return [];
}
}

HandlerInterceptor (namespace dev\winterframework\core\web\HandlerInterceptor) provides application-wide interception. You register interceptors in a WebMvcConfigurer bean and associate each one with one or more URI regex patterns. Only requests whose URI matches a registered pattern pass through that interceptor.

interface HandlerInterceptor
{
public function preHandle(HttpRequest $request, ResponseEntity $response): bool;
public function postHandle(HttpRequest $request, ResponseEntity $response): void;
public function afterCompletion(
HttpRequest $request,
ResponseEntity $response,
?Throwable $ex = null
): void;
}
Method Called when Return value
preHandle() Before the handler method (and before ControllerInterceptor::preHandle) true → continue; false → abort, response sent by this interceptor
postHandle() After the handler method executes successfully void
afterCompletion() After the response is rendered, even if an exception occurred void; $ex is non-null when an exception was thrown
  1. Implement HandlerInterceptor

    Create a class that implements HandlerInterceptor and define your logic in the three lifecycle methods.

    RequestLoggingInterceptor.php
    <?php
    namespace dev\winterboot\samples\interceptor;
    use dev\winterframework\core\web\HandlerInterceptor;
    use dev\winterframework\util\log\Wlf4p;
    use dev\winterframework\web\http\HttpRequest;
    use dev\winterframework\web\http\ResponseEntity;
    use Throwable;
    class RequestLoggingInterceptor implements HandlerInterceptor
    {
    use Wlf4p; // provides self::logInfo(), self::logError(), etc.
    public function preHandle(HttpRequest $request, ResponseEntity $response): bool
    {
    self::logInfo('Incoming ' . $request->getMethod() . ' ' . $request->getUri());
    return true; // always continue
    }
    public function postHandle(HttpRequest $request, ResponseEntity $response): void
    {
    self::logInfo('Handled ' . $request->getUri()
    . ' → HTTP ' . $response->getStatus()->getValue());
    }
    public function afterCompletion(
    HttpRequest $request,
    ResponseEntity $response,
    ?Throwable $ex = null
    ): void {
    if ($ex !== null) {
    self::logError('Exception during ' . $request->getUri() . ': ' . $ex->getMessage());
    }
    self::logInfo('Request complete: ' . $request->getUri());
    }
    }
  2. Register with WebMvcConfigurer

    Annotate a class with #[Configuration(name: 'webMvcConfigurer')] and implement WebMvcConfigurer (namespace dev\winterframework\core\web\config\WebMvcConfigurer). The bean must be named webMvcConfigurer — that is the name the framework looks up. Call $registry->addInterceptor() inside addInterceptors() to register each interceptor with its URI patterns.

    MyWebConfigurer.php
    <?php
    namespace dev\winterboot\samples\config;
    use dev\winterframework\core\web\config\InterceptorRegistry;
    use dev\winterframework\core\web\config\WebMvcConfigurer;
    use dev\winterframework\stereotype\Configuration;
    use dev\winterboot\samples\interceptor\RequestLoggingInterceptor;
    use dev\winterboot\samples\interceptor\AdminAccessInterceptor;
    #[Configuration(name: 'webMvcConfigurer')]
    class MyWebConfigurer implements WebMvcConfigurer
    {
    public function addInterceptors(InterceptorRegistry $registry): void
    {
    // Apply to every URI path
    $registry->addInterceptor(new RequestLoggingInterceptor(), '.*');
    // Apply only to /admin/* and /super/* paths
    $registry->addInterceptor(
    new AdminAccessInterceptor(),
    '^\/admin\/.*',
    '^\/super\/.*'
    );
    }
    }

The AdminAccessInterceptor below demonstrates how to enforce role-based access for a subset of URIs.

AdminAccessInterceptor.php
<?php
namespace dev\winterboot\samples\interceptor;
use dev\winterframework\core\web\HandlerInterceptor;
use dev\winterframework\web\http\HttpRequest;
use dev\winterframework\web\http\HttpStatus;
use dev\winterframework\web\http\ResponseEntity;
use Throwable;
class AdminAccessInterceptor implements HandlerInterceptor
{
public function preHandle(HttpRequest $request, ResponseEntity $response): bool
{
$role = $request->getFirstHeader('X-User-Role');
if ($role !== 'admin') {
$response->withStatus(HttpStatus::$FORBIDDEN)
->withJson(['error' => 'Admin access required']);
return false; // short-circuit — no further interceptors or controller called
}
return true;
}
public function postHandle(HttpRequest $request, ResponseEntity $response): void {}
public function afterCompletion(
HttpRequest $request,
ResponseEntity $response,
?Throwable $ex = null
): void {}
}
public function addInterceptor(HandlerInterceptor $interceptor, string ...$regexPaths): void;
  • $interceptor HandlerInterceptor (required) An instance of HandlerInterceptor to register.

  • $regexPaths string (required) One or more PHP regex patterns without delimiters. The framework wraps each pattern in /…/, so escape forward slashes (\/). Only requests matching at least one pattern are passed to this interceptor. Use '.*' to match every request, or anchored patterns like '^\/api\/.*' to limit an interceptor to part of your API.

Patterns are tested against the request URI as sent and against its normalized form (repeated slashes collapsed), the same form routing uses. A request for //admin/users therefore still passes through an '^\/admin' interceptor before it reaches the admin/users route. The URI includes the context path, if one is configured.

A REST endpoint exposed as an MCP tool is called through the normal pipeline, so HandlerInterceptors run twice per tool call: once for the agent’s POST /mcp request and once for the in-process request to the endpoint. The in-process request carries the agent’s headers and cookies and the full URI, including server.context-path, so path-based rules work unchanged. An interceptor that counts requests, such as a rate limiter, can skip the in-process request:

if ($request instanceof \dev\winterframework\web\http\InternalHttpRequest) {
return true; // already counted for the /mcp request
}

When multiple interceptors are registered, they run in registration order for preHandle and in reverse registration order for postHandle and afterCompletion.

  1. HandlerInterceptor::preHandle

    First registered runs first. If any preHandle returns false, the chain stops immediately — no further preHandle calls are made, and the controller method is not invoked.

  2. ControllerInterceptor::preHandle

    Runs after all HandlerInterceptor::preHandle calls have returned true. This is the per-controller gate check.

  3. Controller method executes

    The matched handler method runs and produces a return value.

  4. HandlerInterceptor::postHandle

    Last registered runs first (reverse order). Only called when no exception was thrown.

  5. ControllerInterceptor::postHandle

    Runs after the HandlerInterceptor::postHandle calls, just before the response is rendered.

  6. HandlerInterceptor::afterCompletion

    Last registered runs first (reverse order). Always called — even when an exception occurred. Only interceptors that already completed their preHandle step have their afterCompletion invoked.