Middleware

Middleware lets you intercept, inspect, and modify HTTP requests and responses as they flow through your application. Melodic uses a pipeline model where each middleware wraps the next, forming an onion-like structure around your route handlers.

The Middleware Pipeline

Every request in Melodic flows through a chain of middleware before reaching your controller. Each middleware can inspect or modify the request, pass it to the next layer, then inspect or modify the response on the way back out.

Request
  → ErrorHandlerMiddleware
    → RequestTimingMiddleware
      → CorsMiddleware
        → JsonBodyParserMiddleware
          → RoutingMiddleware
            → [Route Middleware]
              → Controller Action
            ← Response
          ← Response
        ← Response (+ CORS headers)
      ← Response (+ X-Response-Time header)
    ← Response (exceptions caught)
  ← Response sent to client

Key points about the pipeline:

  • The request enters the first middleware and moves inward through each layer
  • Each middleware calls $handler->handle($request) to pass control to the next layer
  • The innermost layer is the RoutingMiddleware, which matches the route and invokes the controller
  • Responses bubble back through the middleware in reverse order
  • Any middleware can short-circuit the pipeline by returning a Response directly without calling the handler

MiddlewareInterface

All middleware implements Melodic\Http\Middleware\MiddlewareInterface:

<?php

namespace Melodic\Http\Middleware;

use Melodic\Http\Request;
use Melodic\Http\Response;

interface MiddlewareInterface
{
    public function process(Request $request, RequestHandlerInterface $handler): Response;
}

The process() method receives the current request and a handler representing the rest of the pipeline. It must either:

  • Call $handler->handle($request) to continue to the next middleware and return the resulting response
  • Return a Response directly to short-circuit the pipeline (skipping all remaining middleware and the controller)

RequestHandlerInterface

The handler passed to each middleware implements Melodic\Http\Middleware\RequestHandlerInterface:

<?php

namespace Melodic\Http\Middleware;

use Melodic\Http\Request;
use Melodic\Http\Response;

interface RequestHandlerInterface
{
    public function handle(Request $request): Response;
}

You never implement this interface yourself in typical usage. The pipeline constructs handler objects internally to chain your middleware together.

Creating Custom Middleware

To create middleware, implement MiddlewareInterface and follow this pattern:

  1. Receive the request in process()
  2. Optionally inspect or modify the request before passing it along
  3. Call $handler->handle($request) to get the response from the next layer
  4. Optionally inspect or modify the response before returning it
  5. Return the response

Example: Request Timing Middleware

This middleware measures how long the entire downstream pipeline takes and adds a header to the response:

<?php

declare(strict_types=1);

namespace App\Middleware;

use Melodic\Http\Middleware\MiddlewareInterface;
use Melodic\Http\Middleware\RequestHandlerInterface;
use Melodic\Http\Request;
use Melodic\Http\Response;

class RequestTimingMiddleware implements MiddlewareInterface
{
    public function process(Request $request, RequestHandlerInterface $handler): Response
    {
        $start = hrtime(true);

        $response = $handler->handle($request);

        $elapsedMs = (hrtime(true) - $start) / 1_000_000;

        return $response->withHeader('X-Response-Time', round($elapsedMs, 2) . 'ms');
    }
}

Because this middleware calls $handler->handle() in the middle, it wraps the entire pipeline — the timer starts before any downstream middleware runs and stops after all of them have returned.

Example: Logging Middleware

Log every request method and path along with the response status:

<?php

declare(strict_types=1);

namespace App\Middleware;

use Melodic\Http\Middleware\MiddlewareInterface;
use Melodic\Http\Middleware\RequestHandlerInterface;
use Melodic\Http\Request;
use Melodic\Http\Response;

class LoggingMiddleware implements MiddlewareInterface
{
    public function process(Request $request, RequestHandlerInterface $handler): Response
    {
        $method = $request->method()->value;
        $path = $request->path();

        // Process the request through the rest of the pipeline
        $response = $handler->handle($request);

        $status = $response->getStatusCode();
        error_log("[{$method}] {$path} → {$status}");

        return $response;
    }
}

Example: Rate Limiting Middleware (Short-Circuit)

This example demonstrates short-circuiting — returning a response without calling the handler:

<?php

declare(strict_types=1);

namespace App\Middleware;

use Melodic\Http\JsonResponse;
use Melodic\Http\Middleware\MiddlewareInterface;
use Melodic\Http\Middleware\RequestHandlerInterface;
use Melodic\Http\Request;
use Melodic\Http\Response;

class RateLimitMiddleware implements MiddlewareInterface
{
    private array $requests = [];

    public function __construct(
        private readonly int $maxRequests = 60,
        private readonly int $windowSeconds = 60,
    ) {}

    public function process(Request $request, RequestHandlerInterface $handler): Response
    {
        $ip = $request->header('X-Forwarded-For') ?? 'unknown';
        $now = time();

        // Clean old entries
        $this->requests[$ip] = array_filter(
            $this->requests[$ip] ?? [],
            fn(int $time) => $time > $now - $this->windowSeconds,
        );

        if (count($this->requests[$ip]) >= $this->maxRequests) {
            // Short-circuit: return 429 without calling $handler
            return new JsonResponse(
                ['error' => 'Too many requests'],
                429,
            );
        }

        $this->requests[$ip][] = $now;

        return $handler->handle($request);
    }
}

Short-circuiting: When middleware returns a Response without calling $handler->handle(), no downstream middleware or controller is executed. The response travels back through any upstream middleware that already called handle().

Global Middleware

Global middleware is applied to every request. Register it with $app->addMiddleware():

$app->addMiddleware(new RequestTimingMiddleware());
$app->addMiddleware(new CorsMiddleware($corsConfig));
$app->addMiddleware(new JsonBodyParserMiddleware());

Order matters. Middleware is added to the pipeline in the order you call addMiddleware(), and the first middleware added is the outermost layer (runs first on the way in, last on the way out).

In the example above:

  1. RequestTimingMiddleware starts the timer, passes the request inward
  2. CorsMiddleware handles preflight and adds CORS headers
  3. JsonBodyParserMiddleware parses JSON bodies, passes the request to routing
  4. Responses return through each layer in reverse

Tip: Place timing and logging middleware first (outermost) so they measure the full request lifecycle. Place authentication middleware after body parsing so the parsed body is available if needed.

Route-Level Middleware

Route-level middleware applies only to specific routes or route groups. It runs inside the routing layer, after all global middleware has already processed the request.

Per-Route Middleware

$router->get('/admin/dashboard', AdminController::class, 'dashboard', middleware: [
    AuthorizationMiddleware::class,
]);

Per-Group Middleware

Apply middleware to an entire group of routes:

$router->group('/api', function (Router $r) {
    $r->apiResource('/users', UserController::class);
    $r->apiResource('/posts', PostController::class);
}, middleware: [
    ApiAuthenticationMiddleware::class,
]);

All routes inside the group inherit the group's middleware. You can also combine group middleware with per-route middleware — they are merged together, with group middleware running first:

$router->group('/api', function (Router $r) {
    // Gets ApiAuthenticationMiddleware (from group)
    $r->get('/posts', PostController::class, 'index');

    // Gets ApiAuthenticationMiddleware (from group) + AuthorizationMiddleware (from route)
    $r->post('/posts', PostController::class, 'store', middleware: [
        AuthorizationMiddleware::class,
    ]);
}, middleware: [
    ApiAuthenticationMiddleware::class,
]);

How Route Middleware Is Resolved

Route middleware class names are resolved from the DI container, so they can have constructor dependencies injected automatically:

class AuthorizationMiddleware implements MiddlewareInterface
{
    public function __construct(
        private readonly JwtValidator $validator,
    ) {}

    public function process(Request $request, RequestHandlerInterface $handler): Response
    {
        // $validator was injected by the container
        // ...
    }
}

Global vs Route middleware: Global middleware is instantiated by you and passed to addMiddleware(). Route middleware is specified as class names and resolved from the DI container by the RoutingMiddleware. This means route middleware can take advantage of auto-wiring and interface bindings.

Built-In Middleware

ErrorHandlerMiddleware

Melodic\Http\Middleware\ErrorHandlerMiddleware catches any unhandled exceptions thrown during request processing and converts them into structured error responses. It wraps the entire downstream pipeline in a try/catch block.

use Melodic\Http\Middleware\ErrorHandlerMiddleware;
use Melodic\Log\LoggerInterface;

$logger = $container->get(LoggerInterface::class);
$app->addMiddleware(new ErrorHandlerMiddleware($logger, debug: $app->config('app.debug', false)));

Constructor parameters:

ParameterTypeDescription
$loggerLoggerInterfaceLogger used to record exception details
$debugboolWhen true, includes stack traces and exception details in the response. Set to false in production.

Behavior:

  • Wraps the downstream pipeline call in a try/catch for all Throwable exceptions
  • Delegates to the ExceptionHandler to produce a structured JSON error response
  • Logs the exception via the provided logger
  • In debug mode, includes the exception message, file, line, and stack trace in the response
  • In production mode, returns a generic error message without exposing internals

Tip: Register ErrorHandlerMiddleware as the first (outermost) middleware so it can catch exceptions from any layer of the pipeline, including other middleware.

CorsMiddleware

Melodic\Http\Middleware\CorsMiddleware handles Cross-Origin Resource Sharing headers and OPTIONS preflight requests.

Constructor takes a configuration array:

$cors = new CorsMiddleware([
    'allowedOrigins' => ['https://example.com', 'https://app.example.com'],
    'allowedMethods' => ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'],
    'allowedHeaders' => ['Content-Type', 'Authorization'],
    'maxAge'         => 86400,
]);

$app->addMiddleware($cors);
OptionDefaultDescription
allowedOrigins['*']Origins permitted to make requests
allowedMethods['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS']HTTP methods allowed in CORS requests
allowedHeaders['Content-Type', 'Authorization']Request headers allowed in CORS requests
maxAge86400Seconds to cache preflight responses

Behavior:

  • For OPTIONS requests (preflight), returns a 204 No Content response with CORS headers immediately — the rest of the pipeline is not invoked
  • For all other requests, passes the request through, then adds CORS headers to the response on the way back

JsonBodyParserMiddleware

Melodic\Http\Middleware\JsonBodyParserMiddleware parses JSON request bodies into an associative array. No configuration needed.

$app->addMiddleware(new JsonBodyParserMiddleware());

Behavior:

  • Checks the Content-Type header for application/json
  • If found, reads the raw body and parses it with json_decode()
  • Stores the parsed data as a request attribute called parsedBody
  • The parsed body is then accessible via $request->body() in your controllers
  • If the Content-Type is not JSON, the request passes through untouched
// In a controller action, after JsonBodyParserMiddleware has run:
$data = $this->request->body();           // full parsed array
$email = $this->request->body('email');    // single key with null default

RoutingMiddleware

Melodic\Routing\RoutingMiddleware is the internal middleware that connects the pipeline to your routes and controllers. It is added automatically by the framework — you do not register it yourself.

What it does:

  1. Matches the request method and path against registered routes
  2. Extracts route parameters (e.g., {id}) and stores them as request attributes
  3. If the route has middleware, builds a mini-pipeline from those middleware classes (resolved from the DI container)
  4. Resolves the controller from the DI container (with auto-wiring)
  5. Calls the controller action with the route parameters
  6. Returns a 404 JSON response if no route matches

The Pipeline Class

Melodic\Http\Middleware\Pipeline is the class that composes middleware into a chain. It implements RequestHandlerInterface itself, so it can be used anywhere a handler is expected.

<?php

namespace Melodic\Http\Middleware;

class Pipeline implements RequestHandlerInterface
{
    public function __construct(RequestHandlerInterface $fallbackHandler);
    public function pipe(MiddlewareInterface $middleware): self;
    public function handle(Request $request): Response;
}

How It Works

  • The constructor takes a fallbackHandler — the final handler reached if all middleware calls handle()
  • pipe() adds middleware to the chain and returns $this for fluent calls
  • handle() builds the chain recursively: each middleware wraps the next, with the fallback handler at the end
// Manual pipeline construction (this is what the framework does internally)
$pipeline = new Pipeline($fallbackHandler);
$pipeline->pipe(new RequestTimingMiddleware());
$pipeline->pipe(new CorsMiddleware($config));
$pipeline->pipe(new JsonBodyParserMiddleware());

$response = $pipeline->handle($request);

The first middleware piped is the outermost layer. When handle() is called:

  1. RequestTimingMiddleware::process() is called with a handler pointing to step 2
  2. CorsMiddleware::process() is called with a handler pointing to step 3
  3. JsonBodyParserMiddleware::process() is called with a handler pointing to the fallback
  4. The fallback handler runs (routing, controller invocation)

Modifying Requests and Responses

Both Request and Response are effectively immutable — modification methods return new instances rather than changing the original. This is important in middleware where you need to pass a modified object to the next layer.

Modifying Requests

Use withAttribute() to attach data to the request for downstream middleware and controllers:

public function process(Request $request, RequestHandlerInterface $handler): Response
{
    // Add a custom attribute to the request
    $request = $request->withAttribute('requestId', bin2hex(random_bytes(8)));

    // Pass the modified request downstream
    $response = $handler->handle($request);

    return $response;
}

Downstream code can read the attribute:

// In a controller
$requestId = $this->request->getAttribute('requestId');

Immutable pattern: $request->withAttribute() returns a new Request instance. You must reassign the variable and pass the new instance to $handler->handle(). The original request object is not modified.

Modifying Responses

Use withHeader(), withStatus(), or withBody() to modify the response as it travels back through the pipeline:

public function process(Request $request, RequestHandlerInterface $handler): Response
{
    $response = $handler->handle($request);

    // Add security headers on the way out
    $response = $response->withHeader('X-Content-Type-Options', 'nosniff');
    $response = $response->withHeader('X-Frame-Options', 'DENY');
    $response = $response->withHeader('X-XSS-Protection', '1; mode=block');

    return $response;
}

Complete Example: Security Headers Middleware

Putting it all together — a middleware that adds a request ID, passes it downstream, and adds security headers to the response:

<?php

declare(strict_types=1);

namespace App\Middleware;

use Melodic\Http\Middleware\MiddlewareInterface;
use Melodic\Http\Middleware\RequestHandlerInterface;
use Melodic\Http\Request;
use Melodic\Http\Response;

class SecurityHeadersMiddleware implements MiddlewareInterface
{
    public function process(Request $request, RequestHandlerInterface $handler): Response
    {
        // Modify the request: add a unique request ID
        $requestId = bin2hex(random_bytes(8));
        $request = $request->withAttribute('requestId', $requestId);

        // Pass modified request to the next layer
        $response = $handler->handle($request);

        // Modify the response: add security and tracing headers
        return $response
            ->withHeader('X-Request-Id', $requestId)
            ->withHeader('X-Content-Type-Options', 'nosniff')
            ->withHeader('X-Frame-Options', 'DENY')
            ->withHeader('Referrer-Policy', 'strict-origin-when-cross-origin');
    }
}