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:
- Pass through — call
$handler->handle($request)and return the response - Modify and pass — alter the request or response and call the next handler
- 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 Order | Request Phase | Response Phase |
|---|---|---|
| 1st (outermost) | Runs first | Runs last |
| 2nd | Runs second | Runs second-to-last |
| 3rd (innermost) | Runs last | Runs 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
| File | Purpose |
|---|---|
src/Middleware/RateLimitMiddleware.php | Throttles clients by IP, returns 429 when exceeded |
src/Middleware/MaintenanceModeMiddleware.php | Returns 503 when maintenance mode is active |
src/Middleware/PersistentRateLimitMiddleware.php | Cache-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.