Cookbook

Writing Middleware

Build custom RouteMiddleware — the interface, runtime parameters, registering aliases, and applying middleware to routes and groups.

This page is about writing your own middleware: the RouteMiddleware contract, runtime parameters, registering custom aliases, and applying middleware.

RouteMiddleware Interface

The core middleware contract provides flexibility with parameter passing while maintaining clean semantics:

<?php
namespace Glueful\Routing;

use Symfony\Component\HttpFoundation\Request;

interface RouteMiddleware
{
    /**
     * Handle middleware processing
     *
     * @param Request $request The HTTP request being processed
     * @param callable $next Next handler in the pipeline - call $next($request) to continue
     * @param mixed ...$params Additional parameters from route or middleware config
     * @return Response|mixed Response object or data to be normalized
     */
    public function handle(Request $request, callable $next, mixed ...$params): mixed;
}

Basic Implementation Pattern

use Glueful\Routing\RouteMiddleware;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;

class ExampleMiddleware implements RouteMiddleware
{
    public function handle(Request $request, callable $next, mixed ...$params): mixed
    {
        // Pre-processing: modify request, check conditions, etc.
        $request->attributes->set('processed_at', time());
        
        // Call next middleware or final handler
        $response = $next($request);
        
        // Post-processing: modify response, add headers, log, etc.
        $response->headers->set('X-Processed-By', 'ExampleMiddleware');
        
        return $response;
    }
}

Creating Custom Middleware

Simple Middleware

use Glueful\Routing\RouteMiddleware;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;

class TimingMiddleware implements RouteMiddleware
{
    public function handle(Request $request, callable $next): mixed
    {
        $start = microtime(true);
        
        $response = $next($request);
        
        $duration = round((microtime(true) - $start) * 1000, 2);
        $response->headers->set('X-Response-Time', $duration . 'ms');
        
        return $response;
    }
}

Middleware with Dependencies

use Glueful\Routing\RouteMiddleware;
use Symfony\Component\HttpFoundation\Request;
use Psr\Log\LoggerInterface;
use App\Services\AuditService;

class AuditMiddleware implements RouteMiddleware
{
    public function __construct(
        private LoggerInterface $logger,
        private AuditService $auditService
    ) {}
    
    public function handle(Request $request, callable $next): mixed
    {
        // Log request
        $this->logger->info('Request started', [
            'method' => $request->getMethod(),
            'uri' => $request->getRequestUri(),
            'ip' => $request->getClientIp()
        ]);
        
        $response = $next($request);
        
        // Audit the action
        $this->auditService->recordAction([
            'user_id' => $request->attributes->get('user_id'),
            'action' => $request->attributes->get('_route'),
            'status' => $response->getStatusCode()
        ]);
        
        return $response;
    }
}

Middleware with Conditional Logic

class MaintenanceMiddleware implements RouteMiddleware
{
    public function __construct(
        private string $maintenanceFile = '/tmp/maintenance'
    ) {}
    
    public function handle(Request $request, callable $next): mixed
    {
        if ($this->isInMaintenanceMode() && !$this->isExemptRoute($request)) {
            return new Response('Service temporarily unavailable', 503, [
                'Retry-After' => 3600,
                'Content-Type' => 'application/json'
            ]);
        }
        
        return $next($request);
    }
    
    private function isInMaintenanceMode(): bool
    {
        return file_exists($this->maintenanceFile);
    }
    
    private function isExemptRoute(Request $request): bool
    {
        $exemptRoutes = ['/health', '/status'];
        return in_array($request->getPathInfo(), $exemptRoutes);
    }
}

Middleware Parameters

The framework supports runtime parameters for configurable middleware behavior:

Parameter Extraction

class CacheMiddleware implements RouteMiddleware
{
    public function handle(Request $request, callable $next, mixed ...$params): mixed
    {
        // Extract parameters with defaults
        $ttl = (int) ($params[0] ?? 300); // Default 5 minutes
        $tags = isset($params[1]) ? explode(',', $params[1]) : ['default'];
        $varyBy = (string) ($params[2] ?? 'path');
        
        $cacheKey = $this->generateCacheKey($request, $varyBy);
        
        // Check cache
        $cache = app($context, Glueful\Cache\CacheStore::class);
        if ($cached = $cache->get($cacheKey)) {
            return new Response($cached);
        }
        
        $response = $next($request);
        
        // Store in cache with TTL (add tags if your driver supports them)
        $cache->set($cacheKey, (string) $response->getContent(), $ttl);
        // Optionally: $cache->addTags($cacheKey, $tags);
        
        return $response;
    }
}

// Usage with parameters
$router->get('/api/expensive-data', [DataController::class, 'expensive'])
    ->middleware('cache:600,data,user'); // 10min TTL, 'data' tag, vary by user

Parameter Parsing Patterns

class FlexibleMiddleware implements RouteMiddleware
{
    public function handle(Request $request, callable $next, mixed ...$params): mixed
    {
        $config = $this->parseParams($params);
        
        // Use parsed configuration
        if ($config['strict_mode']) {
            // Strict validation
        }
        
        return $next($request);
    }
    
    private function parseParams(array $params): array
    {
        $config = [
            'timeout' => 30,
            'strict_mode' => false,
            'allowed_types' => ['json', 'xml']
        ];
        
        foreach ($params as $param) {
            if (is_numeric($param)) {
                $config['timeout'] = (int) $param;
            } elseif ($param === 'strict') {
                $config['strict_mode'] = true;
            } elseif (str_contains($param, ',')) {
                $config['allowed_types'] = explode(',', $param);
            }
        }
        
        return $config;
    }
}

// Usage: ->middleware('flexible:60,strict,json,xml')

Applying Middleware

Container vs Direct Instantiation

The framework supports two approaches for applying middleware: using container-registered aliases (recommended for standard cases) and direct instantiation (for custom configurations).

Method 1: Container-Registered Middleware (String Aliases)

Use string aliases when framework defaults are sufficient:

// Standard authentication using framework defaults
$router->get('/profile', [UserController::class, 'show'])
    ->middleware('auth');

// Multiple middleware with parameters
$router->post('/admin/users', [AdminController::class, 'createUser'])
    ->middleware(['auth:admin', 'csrf', 'rate_limit:10,60']);

// Group middleware
$router->group(['middleware' => ['auth']], function($router) {
    $router->get('/dashboard', [DashboardController::class, 'index']);
    $router->get('/settings', [SettingsController::class, 'index']);
});

Method 2: Direct Instantiation with Custom Configuration

Use direct instantiation when you need custom configuration:

// Custom API authentication - API keys only, no expiration validation
$apiAuthMiddleware = new AuthMiddleware(
    authManager: null,
    container: null,
    providerNames: ['api_key'], // Only API keys for this endpoint
    options: [
        'validate_expiration' => false,  // Lenient for API
        'enable_events' => false,        // High performance
        'enable_logging' => true         // But track usage
    ]
);

$router->get('/api/public-data', [PublicApiController::class, 'getData'])
    ->middleware([$apiAuthMiddleware]);

// Different configuration for internal API
$internalAuthMiddleware = new AuthMiddleware(
    providerNames: ['jwt'],
    options: [
        'validate_expiration' => true,   // Strict for internal
        'enable_events' => true,
        'enable_logging' => true
    ]
);

$router->group(['middleware' => [$internalAuthMiddleware]], function($router) {
    $router->get('/internal/metrics', [InternalController::class, 'metrics']);
    $router->post('/internal/admin-action', [InternalController::class, 'adminAction'])
        ->middleware('admin'); // Can still mix with string aliases
});

Combine both approaches strategically:

// Use string aliases for standard cases
$router->group(['middleware' => ['auth']], function($router) {
    
    // Standard routes use framework defaults
    $router->get('/profile', [UserController::class, 'profile']);
    $router->get('/dashboard', [DashboardController::class, 'index']);
    
    // Special routes use custom middleware instances
    $router->get('/experimental-feature', [ExperimentalController::class, 'index'])
        ->middleware([
            new App\Middleware\FeatureFlagMiddleware('experimental_ui'),
            new App\Middleware\ABTestMiddleware()
        ]);
});

Container Registration (custom aliases)

To add your own string alias for middleware, load a binding into the container (e.g., during bootstrap or in a provider):

$container = container($context);
$container->load([
    // String alias => middleware instance (or factory/definition)
    'custom_rate_limit' => new App\Middleware\CustomRateLimitMiddleware(1000, 3600),
]);

// Then use it in routes
$router->get('/api/data', [ApiController::class, 'getData'])
    ->middleware(['custom_rate_limit', 'auth']);

Registering Custom Middleware in Container

For your own middleware that you want to use with string aliases:

// In your ServiceProvider
class AppServiceProvider implements ServiceProviderInterface
{
    public function register(ContainerBuilder $container): void
    {
        // Register custom middleware
        $container->register(App\Middleware\CustomRateLimitMiddleware::class)
            ->setArguments([1000, 3600]) // maxRequests, windowSeconds
            ->setPublic(true);

        // Create string alias
        $container->setAlias('custom_rate_limit', App\Middleware\CustomRateLimitMiddleware::class)
            ->setPublic(true);
    }
}

// Then use with string:
$router->get('/api/data', [ApiController::class, 'getData'])
    ->middleware(['custom_rate_limit', 'auth']);

Route-Level Middleware

// Single middleware
$router->get('/protected', [SecureController::class, 'data'])
    ->middleware('auth');

// Multiple middleware (executed in order)
$router->post('/api/upload', [UploadController::class, 'store'])
    ->middleware(['auth', 'rate_limit:10,60', 'csrf']);

// Middleware with parameters
$router->get('/cached-data', [DataController::class, 'expensive'])
    ->middleware(['cache:300,data', 'rate_limit:100,60']);

Group-Level Middleware

// Apply middleware to entire groups
$router->group(['middleware' => ['auth', 'rate_limit:1000,3600']], function($router) {
    $router->get('/dashboard', [DashboardController::class, 'index']);
    $router->get('/profile', [ProfileController::class, 'show']);
    $router->put('/profile', [ProfileController::class, 'update']);
});

// Nested groups inherit parent middleware
$router->group(['middleware' => ['auth']], function($router) {
    
    // User routes (inherits 'auth')
    $router->get('/user/data', [UserController::class, 'data']);
    
    // Admin routes (inherits 'auth', adds 'admin')
    $router->group(['prefix' => '/admin', 'middleware' => ['admin_permission']], function($router) {
        $router->get('/users', [AdminController::class, 'users']);
        $router->delete('/users/{id}', [AdminController::class, 'deleteUser']);
    });
});

Global Middleware

// Apply to all routes (in bootstrap or service provider)
$router->group(['middleware' => ['security_headers', 'metrics']], function($router) {
    // Load all application routes
    require __DIR__ . '/routes/api.php';
    require __DIR__ . '/routes/web.php';
});

Best Practices

1. Middleware Ordering

Order middleware strategically for optimal performance and security:

// Recommended order:
$router->group(['middleware' => [
    'security_headers',    // 1. Apply security headers early
    'rate_limit:100,60',  // 2. Rate limiting before expensive operations
    'auth',               // 3. Authentication check
    'csrf',               // 4. CSRF validation (requires auth context)
    'admin_permission',   // 5. Authorization checks
    'field_selection',    // 6. Query optimization
    'metrics',            // 7. Metrics collection
    'request_logging'     // 8. Logging (captures full context)
]], function($router) {
    // Protected routes
});

2. Parameter Configuration

Use clear, consistent parameter patterns:

// Good: Clear parameter meanings
->middleware('rate_limit:100,60,user')  // max, window, type
->middleware('cache:300,api-data,user') // ttl, tag, vary-by

// Bad: Unclear parameters
->middleware('config:100,60,1,user')    // What do these numbers mean?

3. Error Handling

Provide meaningful error responses:

class AuthMiddleware implements RouteMiddleware
{
    public function handle(Request $request, callable $next, mixed ...$params): mixed
    {
        try {
            $user = $this->authService->authenticate($request);
        } catch (AuthenticationException $e) {
            return new JsonResponse([
                'error' => 'Authentication failed',
                'message' => 'Please provide valid credentials',
                'code' => 'AUTH_FAILED'
            ], 401);
        }
        
        $request->attributes->set('user', $user);
        return $next($request);
    }
}

4. Performance Considerations

class CachingMiddleware implements RouteMiddleware
{
    private array $cache = []; // In-memory cache for single request
    
    public function handle(Request $request, callable $next, mixed ...$params): mixed
    {
        $cacheKey = $this->getCacheKey($request);
        
        // Check in-memory cache first (fastest)
        if (isset($this->cache[$cacheKey])) {
            return $this->cache[$cacheKey];
        }
        
        // Check distributed cache
        if ($cached = $this->redis->get($cacheKey)) {
            return $this->cache[$cacheKey] = unserialize($cached);
        }
        
        $response = $next($request);
        
        // Store in both caches
        $this->cache[$cacheKey] = $response;
        $this->redis->setex($cacheKey, 300, serialize($response));
        
        return $response;
    }
}

5. Testing Middleware

use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;

class RateLimitMiddlewareTest extends TestCase
{
    public function test_allows_requests_within_limit(): void
    {
        $middleware = new RateLimiterMiddleware(maxAttempts: 5, windowSeconds: 60);
        
        $request = Request::create('/test', 'GET');
        $next = fn($req) => new Response('OK');
        
        // First request should pass
        $response = $middleware->handle($request, $next);
        $this->assertEquals(200, $response->getStatusCode());
    }
    
    public function test_blocks_requests_over_limit(): void
    {
        $middleware = new RateLimiterMiddleware(maxAttempts: 1, windowSeconds: 60);
        
        $request = Request::create('/test', 'GET');
        $next = fn($req) => new Response('OK');
        
        // First request passes
        $middleware->handle($request, $next);
        
        // Second request should be blocked
        $response = $middleware->handle($request, $next);
        $this->assertEquals(429, $response->getStatusCode());
    }
}