Writing Custom Middleware

In this tutorial you will build two practical middleware classes from scratch: a rate limiter that returns 429 responses when clients exceed a request threshold, and a maintenance mode gate that returns 503 pages when activated. Along the way you will learn how the middleware pipeline works and how to register middleware globally or per-route.

What you will build: Two production-ready middleware classes that demonstrate short-circuiting, request inspection, response generation, and configuration-driven behavior.

Step 1: The Middleware Interface

Every middleware in Melodic implements MiddlewareInterface with a single method:

<?php

namespace Melodic\Http\Middleware;

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

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

The RequestHandlerInterface represents the next step in the pipeline:

<?php

namespace Melodic\Http\Middleware;

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

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

A middleware has three choices:

  1. Pass through — call $handler->handle($request) and return the response
  2. Modify and pass — alter the request or response and call the next handler
  3. Short-circuit — return a response directly without calling the next handler
// 1. Pass through
public function process(Request $request, RequestHandlerInterface $handler): Response
{
    return $handler->handle($request);
}

// 2. Modify the request, then pass through
public function process(Request $request, RequestHandlerInterface $handler): Response
{
    $request = $request->withAttribute('startTime', microtime(true));
    $response = $handler->handle($request);

    return $response;
}

// 3. Short-circuit (block the request)
public function process(Request $request, RequestHandlerInterface $handler): Response
{
    return new Response(statusCode: 403, body: 'Forbidden');
}

Step 2: Build RateLimitMiddleware

This middleware tracks the number of requests per IP address within a time window. When a client exceeds the limit, the middleware short-circuits and returns a 429 Too Many Requests response.

Create src/Middleware/RateLimitMiddleware.php:

<?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
{
    /**
     * @var array<string, array{count: int, resetAt: int}>
     */
    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 = $this->getClientIp($request);
        $now = time();

        // Clean up expired entries
        if (isset($this->requests[$ip]) && $this->requests[$ip]['resetAt'] <= $now) {
            unset($this->requests[$ip]);
        }

        // Initialize tracking for this IP
        if (!isset($this->requests[$ip])) {
            $this->requests[$ip] = [
                'count' => 0,
                'resetAt' => $now + $this->windowSeconds,
            ];
        }

        $this->requests[$ip]['count']++;

        // Check if the limit has been exceeded
        if ($this->requests[$ip]['count'] > $this->maxRequests) {
            $retryAfter = $this->requests[$ip]['resetAt'] - $now;

            return new JsonResponse(
                data: [
                    'error' => 'Too many requests. Please try again later.',
                    'retry_after' => $retryAfter,
                ],
                statusCode: 429,
                headers: [
                    'Retry-After' => (string) $retryAfter,
                    'X-RateLimit-Limit' => (string) $this->maxRequests,
                    'X-RateLimit-Remaining' => '0',
                ],
            );
        }

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

        // We cannot add headers to the response directly, but we can
        // create a new response with the rate limit info. For simplicity,
        // we return the response as-is here.
        return $response;
    }

    private function getClientIp(Request $request): string
    {
        // Check for forwarded IP (behind a reverse proxy)
        $forwarded = $request->header('X-Forwarded-For');

        if ($forwarded !== null) {
            // Take the first IP in the chain
            $ips = explode(',', $forwarded);

            return trim($ips[0]);
        }

        return $_SERVER['REMOTE_ADDR'] ?? '127.0.0.1';
    }
}

In-memory storage: This implementation stores request counts in a PHP array, which resets on every process restart. In production, use a shared store like Redis or the CacheInterface for persistence across requests.

Using CacheInterface for persistent rate limiting

For a production-ready version, inject the cache:

<?php

declare(strict_types=1);

namespace App\Middleware;

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

class PersistentRateLimitMiddleware implements MiddlewareInterface
{
    public function __construct(
        private readonly CacheInterface $cache,
        private readonly int $maxRequests = 60,
        private readonly int $windowSeconds = 60,
    ) {}

    public function process(Request $request, RequestHandlerInterface $handler): Response
    {
        $ip = $_SERVER['REMOTE_ADDR'] ?? '127.0.0.1';
        $key = 'rate_limit:' . $ip;

        $count = (int) $this->cache->get($key, 0);
        $count++;

        if ($count > $this->maxRequests) {
            return new JsonResponse(
                data: ['error' => 'Too many requests.'],
                statusCode: 429,
                headers: ['Retry-After' => (string) $this->windowSeconds],
            );
        }

        // Store with TTL so it auto-expires
        $this->cache->set($key, $count, $this->windowSeconds);

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

Step 3: Build MaintenanceModeMiddleware

This middleware checks a configuration flag and returns a 503 Service Unavailable page when maintenance mode is active.

Create src/Middleware/MaintenanceModeMiddleware.php:

<?php

declare(strict_types=1);

namespace App\Middleware;

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

class MaintenanceModeMiddleware implements MiddlewareInterface
{
    public function __construct(
        private readonly Configuration $config,
        private readonly ?string $allowedIp = null,
    ) {}

    public function process(Request $request, RequestHandlerInterface $handler): Response
    {
        $maintenanceEnabled = (bool) $this->config->get('app.maintenance', false);

        if (!$maintenanceEnabled) {
            // Maintenance mode is off — pass through normally
            return $handler->handle($request);
        }

        // Allow a specific IP to bypass maintenance (for testing in production)
        if ($this->allowedIp !== null) {
            $clientIp = $_SERVER['REMOTE_ADDR'] ?? '';

            if ($clientIp === $this->allowedIp) {
                return $handler->handle($request);
            }
        }

        // Short-circuit: return a 503 maintenance page
        $html = $this->renderMaintenancePage();

        return new Response(
            statusCode: 503,
            body: $html,
            headers: [
                'Content-Type' => 'text/html; charset=UTF-8',
                'Retry-After' => '3600',
            ],
        );
    }

    private function renderMaintenancePage(): string
    {
        $appName = $this->config->get('app.name', 'Application');
        $message = $this->config->get('app.maintenance_message', 'We are currently performing scheduled maintenance.');

        return <<<HTML
        <!DOCTYPE html>
        <html lang="en">
        <head>
            <meta charset="UTF-8">
            <meta name="viewport" content="width=device-width, initial-scale=1.0">
            <title>Maintenance — {$appName}</title>
            <style>
                body { font-family: system-ui, sans-serif; display: flex; align-items: center;
                       justify-content: center; min-height: 100vh; margin: 0; background: #f9fafb; }
                .container { text-align: center; max-width: 500px; padding: 2rem; }
                h1 { font-size: 1.5rem; color: #111; margin-bottom: 0.5rem; }
                p { color: #6b7280; line-height: 1.6; }
            </style>
        </head>
        <body>
            <div class="container">
                <h1>Under Maintenance</h1>
                <p>{$message}</p>
                <p>Please check back soon.</p>
            </div>
        </body>
        </html>
        HTML;
    }
}

Add the maintenance flag to your config/config.json:

{
    "app": {
        "name": "My App",
        "debug": true,
        "maintenance": false,
        "maintenance_message": "We are upgrading our systems. Back in 30 minutes."
    }
}

Set "maintenance": true to activate maintenance mode. Set it back to false to disable it.

Step 4: Register Middleware

There are two ways to register middleware: globally (runs on every request) or per-route (runs only on matched routes).

Global Registration

Global middleware runs on every request, in the order registered. Use addMiddleware() on the application:

<?php

$app = new Application(dirname(__DIR__));
$app->loadConfig('config/config.json');

// Global middleware — runs on ALL requests
$app->addMiddleware(new MaintenanceModeMiddleware(
    $app->getConfiguration(),
    allowedIp: '192.168.1.100', // Your IP for bypass
));

$app->addMiddleware(new RateLimitMiddleware(
    maxRequests: 100,
    windowSeconds: 60,
));

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

Tip: Register MaintenanceModeMiddleware first so it can block requests before any other processing occurs. This means even rate limiting will be skipped during maintenance.

Per-Route Registration

Per-route middleware is specified in the route or group definition. It runs only when that route matches:

$app->routes(function ($router) {
    // Rate limit only the API routes
    $router->group('/api', function ($router) {
        $router->apiResource('/tasks', TaskApiController::class);
    }, middleware: [RateLimitMiddleware::class]);

    // No rate limiting on web routes
    $router->get('/', HomeController::class, 'index');
});

When using per-route middleware with class names, the DI container resolves the middleware instance. This means you need to register it in the container with the desired constructor arguments:

$app->services(function ($container) {
    $container->singleton(RateLimitMiddleware::class, function () {
        return new RateLimitMiddleware(maxRequests: 30, windowSeconds: 60);
    });
});

Step 5: Middleware Execution Order

Understanding the order of execution is critical. Middleware forms a nested pipeline:

Request → Middleware A → Middleware B → Middleware C → Router → Controller
                                                                         ↓
Response ← Middleware A ← Middleware B ← Middleware C ← Router ← Controller

Each middleware wraps the next. The first middleware registered is the outermost layer. It sees the request first and the response last.

Registration OrderRequest PhaseResponse Phase
1st (outermost)Runs firstRuns last
2ndRuns secondRuns second-to-last
3rd (innermost)Runs lastRuns first

A typical global middleware stack:

// 1. Maintenance mode   — block everything if active
$app->addMiddleware(new MaintenanceModeMiddleware($config));

// 2. Rate limiting      — throttle abusive clients
$app->addMiddleware(new RateLimitMiddleware(100, 60));

// 3. CORS               — add cross-origin headers
$app->addMiddleware(new CorsMiddleware($corsConfig));

// 4. JSON body parser   — parse application/json bodies
$app->addMiddleware(new JsonBodyParserMiddleware());

// 5. (Routing is always the final handler in the pipeline)

Short-circuiting: When a middleware returns a response without calling $handler->handle(), all subsequent middleware and the router are skipped. The response travels back up through the middleware that already ran.

Step 6: Testing Middleware

You can test middleware in isolation by constructing a Request and a mock handler. No HTTP server required.

<?php

declare(strict_types=1);

use App\Middleware\RateLimitMiddleware;
use Melodic\Http\Middleware\RequestHandlerInterface;
use Melodic\Http\Request;
use Melodic\Http\Response;
use PHPUnit\Framework\TestCase;

class RateLimitMiddlewareTest extends TestCase
{
    public function testAllowsRequestsUnderLimit(): void
    {
        $middleware = new RateLimitMiddleware(maxRequests: 5, windowSeconds: 60);

        $request = new Request(
            server: ['REQUEST_METHOD' => 'GET', 'REQUEST_URI' => '/api/tasks', 'REMOTE_ADDR' => '1.2.3.4'],
        );

        $handler = new class implements RequestHandlerInterface {
            public function handle(Request $request): Response
            {
                return new Response(statusCode: 200, body: 'OK');
            }
        };

        // First 5 requests should pass through
        for ($i = 0; $i < 5; $i++) {
            $response = $middleware->process($request, $handler);
            $this->assertSame(200, $response->getStatusCode());
        }
    }

    public function testBlocksRequestsOverLimit(): void
    {
        $middleware = new RateLimitMiddleware(maxRequests: 3, windowSeconds: 60);

        $request = new Request(
            server: ['REQUEST_METHOD' => 'GET', 'REQUEST_URI' => '/api/tasks', 'REMOTE_ADDR' => '5.6.7.8'],
        );

        $handler = new class implements RequestHandlerInterface {
            public function handle(Request $request): Response
            {
                return new Response(statusCode: 200, body: 'OK');
            }
        };

        // Exhaust the limit
        for ($i = 0; $i < 3; $i++) {
            $middleware->process($request, $handler);
        }

        // 4th request should be blocked
        $response = $middleware->process($request, $handler);
        $this->assertSame(429, $response->getStatusCode());
    }
}

Testing the maintenance middleware:

<?php

declare(strict_types=1);

use App\Middleware\MaintenanceModeMiddleware;
use Melodic\Core\Configuration;
use Melodic\Http\Middleware\RequestHandlerInterface;
use Melodic\Http\Request;
use Melodic\Http\Response;
use PHPUnit\Framework\TestCase;

class MaintenanceModeMiddlewareTest extends TestCase
{
    public function testPassesThroughWhenMaintenanceIsOff(): void
    {
        $config = new Configuration();
        $config->set('app.maintenance', false);

        $middleware = new MaintenanceModeMiddleware($config);

        $request = new Request(
            server: ['REQUEST_METHOD' => 'GET', 'REQUEST_URI' => '/'],
        );

        $handler = new class implements RequestHandlerInterface {
            public function handle(Request $request): Response
            {
                return new Response(statusCode: 200, body: 'Welcome');
            }
        };

        $response = $middleware->process($request, $handler);
        $this->assertSame(200, $response->getStatusCode());
    }

    public function testReturns503WhenMaintenanceIsOn(): void
    {
        $config = new Configuration();
        $config->set('app.maintenance', true);
        $config->set('app.name', 'Test App');

        $middleware = new MaintenanceModeMiddleware($config);

        $request = new Request(
            server: ['REQUEST_METHOD' => 'GET', 'REQUEST_URI' => '/'],
        );

        $handler = new class implements RequestHandlerInterface {
            public function handle(Request $request): Response
            {
                return new Response(statusCode: 200, body: 'Should not reach here');
            }
        };

        $response = $middleware->process($request, $handler);
        $this->assertSame(503, $response->getStatusCode());
    }
}

Key testing pattern: Create a Request with the constructor, create an anonymous class handler that returns a known response, call $middleware->process(), and assert the result. No framework bootstrap required.

What You Built

FilePurpose
src/Middleware/RateLimitMiddleware.phpThrottles clients by IP, returns 429 when exceeded
src/Middleware/MaintenanceModeMiddleware.phpReturns 503 when maintenance mode is active
src/Middleware/PersistentRateLimitMiddleware.phpCache-backed rate limiter for production

You now understand the three core middleware operations (pass-through, modify, and short-circuit), how to register middleware globally and per-route, how execution order affects request processing, and how to test middleware in isolation.

Next steps: Read the Middleware documentation for details on the built-in middleware, or learn to test your entire application with Melodic's test doubles.