Cookbook

Writing Middleware

Build custom RouteMiddleware — the interface, runtime parameters, registering aliases, and testing.

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

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

Every handle() must keep the interface's exact signature, handle(Request $request, callable $next, mixed ...$params): mixed. Dropping mixed ...$params is a fatal error when the class loads.

Simple Middleware

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

class TimingMiddleware implements RouteMiddleware
{
    public function handle(Request $request, callable $next, mixed ...$params): 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

Constructor dependencies are injected when the container builds the middleware (register it with 'autowire' => true, see Registering a Custom Alias):

use Glueful\Routing\RouteMiddleware;
use Psr\Log\LoggerInterface;
use Symfony\Component\HttpFoundation\Request;
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 ...$params): mixed
    {
        $this->logger->info('Request started', [
            'method' => $request->getMethod(),
            'uri' => $request->getPathInfo(),
            'ip' => $request->getClientIp(),
        ]);

        $response = $next($request);

        // After `auth`, the authenticated principal is the 'user' request attribute (an array)
        $user = $request->attributes->get('user');

        $this->auditService->recordAction([
            'user_uuid' => is_array($user) ? ($user['uuid'] ?? null) : null,
            'path' => $request->getPathInfo(),
            'status' => $response->getStatusCode(),
        ]);

        return $response;
    }
}

Middleware with Conditional Logic

Return a response early to short-circuit the pipeline. Glueful\Http\Response::error() produces the framework's standard error envelope (success: false, message, error.code, error.request_id, ...):

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

class MaintenanceMiddleware implements RouteMiddleware
{
    public function __construct(
        private string $maintenanceFile = '/tmp/maintenance'
    ) {}

    public function handle(Request $request, callable $next, mixed ...$params): mixed
    {
        if (file_exists($this->maintenanceFile) && !$this->isExempt($request)) {
            $response = Response::error('Service temporarily unavailable', 503);
            $response->headers->set('Retry-After', '3600');
            return $response;
        }

        return $next($request);
    }

    private function isExempt(Request $request): bool
    {
        return in_array($request->getPathInfo(), ['/healthz', '/ready'], true);
    }
}

For whole-site maintenance, the built-in lockdown middleware and php glueful security:lockdown may already cover it.

You can also throw: the exception handler renders Glueful\Http\Exceptions\Client\UnauthorizedException, ForbiddenException, TooManyRequestsException and the other client exceptions with the right status.

Middleware Parameters

->middleware('name:a,b,c') splits everything after the first : on commas, trims each piece, and passes them to handle() as ...$params. They always arrive as strings, so cast them yourself ((int), and compare booleans explicitly: (bool) 'false' is true).

The framework's own middleware use Glueful\Routing\Middleware\MiddlewareParams for this, and you can too: MiddlewareParams::int($params[0] ?? null) accepts 300 or "300", and MiddlewareParams::bool(...) accepts true/false or "true"/"false"/"1"/"0". Both return null for anything else, so ?? $default keeps your default:

use Glueful\Routing\Middleware\MiddlewareParams;

$ttl = MiddlewareParams::int($params[0] ?? null) ?? 300;
$strict = MiddlewareParams::bool($params[1] ?? null) ?? false;

Parameter Extraction

use Glueful\Cache\CacheStore;
use Glueful\Http\Response;
use Glueful\Routing\RouteMiddleware;
use Symfony\Component\HttpFoundation\Request;

class PageCacheMiddleware implements RouteMiddleware
{
    public function __construct(private CacheStore $cache) {}

    public function handle(Request $request, callable $next, mixed ...$params): mixed
    {
        $ttl = (int) ($params[0] ?? 300);           // default 5 minutes
        $varyBy = (string) ($params[1] ?? 'path');  // 'path' or 'query'

        if ($request->getMethod() !== 'GET') {
            return $next($request);
        }

        $key = 'page:' . md5($varyBy === 'query' ? $request->getRequestUri() : $request->getPathInfo());

        $cached = $this->cache->get($key);
        if (is_array($cached)) {
            return new Response($cached['body'], 200, [], true); // body is already JSON
        }

        $response = $next($request);

        if ($response->getStatusCode() === 200) {
            $this->cache->set($key, ['body' => (string) $response->getContent()], $ttl);
        }

        return $response;
    }
}

// Registered as 'page_cache' (see below), then:
$router->get('/catalog', [CatalogController::class, 'index'])
    ->middleware('page_cache:600,query'); // 10 minutes, vary by full URI

Only cache responses that are the same for every caller; never cache authenticated, per-user responses under a shared key.

Parameter Parsing Patterns

Because commas separate params, a param can't contain a comma itself. Use another separator for lists:

class FlexibleMiddleware implements RouteMiddleware
{
    public function handle(Request $request, callable $next, mixed ...$params): mixed
    {
        $config = $this->parseParams($params);

        if ($config['strict_mode']) {
            // Strict validation
        }

        return $next($request);
    }

    /** @param array<int, string> $params */
    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_starts_with($param, 'types=')) {
                $config['allowed_types'] = explode('|', substr($param, 6));
            }
        }

        return $config;
    }
}

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

Registering a Custom Alias

The router resolves middleware strings from the container, and doesn't autowire unregistered classes. Register your middleware in an app provider's services() and give it an alias:

// app/Providers/AppServiceProvider.php
public static function services(): array
{
    return [
        \App\Middleware\TimingMiddleware::class => [
            'autowire' => true,
            'alias' => 'timing',
        ],
        \App\Middleware\PageCacheMiddleware::class => [
            'autowire' => true,
            'alias' => 'page_cache',
        ],
        // Fixed constructor arguments instead of autowiring
        'maintenance' => [
            'class' => \App\Middleware\MaintenanceMiddleware::class,
            'arguments' => ['/var/run/app/maintenance'],
        ],
    ];
}
$router->get('/catalog', [CatalogController::class, 'index'])
    ->middleware(['timing', 'page_cache:600']);

Route entries must be strings (or closures); middleware instances can't be passed to ->middleware(). To use two configurations of the same class, register two ids with different arguments.

Applying Middleware

Apply middleware per route, per group, or app-wide by wrapping the routes in a route file in a group. Ordering, the built-in alias catalog and the ->rateLimit() builder are covered in Advanced → Middleware.

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

    // Nested groups inherit the parent's middleware
    $router->group(['prefix' => '/admin', 'middleware' => ['admin']], function ($router) {
        $router->get('/users', [AdminController::class, 'users']);
    });
});

Best Practices

1. Order Cheap Rejections First

Put middleware that rejects cheaply (rate limiting, authentication) before middleware that does work, and logging or metrics where they see what you want recorded. See Middleware Ordering.

2. Parameter Configuration

Keep params short and positional, document them in the class docblock, and parse them in one place:

// Good: clear positional meaning (ttl, vary-by)
->middleware('page_cache:300,query')

// Bad: unclear
->middleware('config:100,60,1,user')

Built-in middleware are configured mainly through route builders, attributes, or config; their string params (rate_limit:60,60,user, csrf:3600,false,false, request_logging:both,true,false) are per-route overrides. See the alias catalog.

3. Error Handling

Return Glueful\Http\Response::error() (or Response::unauthorized() / Response::forbidden()), or throw a client exception, so errors share the framework's envelope:

use Glueful\Http\Exceptions\Client\BadRequestException;
use Glueful\Http\Exceptions\Client\UnauthorizedException;

public function handle(Request $request, callable $next, mixed ...$params): mixed
{
    if ($request->attributes->get('user') === null) {
        throw new UnauthorizedException('Authentication required');   // 401
    }

    if (!$request->headers->has('X-Client-Version')) {
        throw new BadRequestException('X-Client-Version header is required'); // 400
    }

    return $next($request);
}

For permission checks, prefer #[RequiresPermission] / #[RequiresRole] on the handler, which the router enforces through gate_permissions.

4. Performance Considerations

Middleware runs on every matching request. Keep handle() cheap, inject services rather than resolving them per request, skip work for methods or paths that don't need it, and cache only data that's safe to share (see Advanced → Performance).

5. Testing Middleware

Middleware is a plain class: construct it, call handle() with a Symfony Request and a $next closure, and assert on the response:

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

final class TimingMiddlewareTest extends TestCase
{
    public function test_adds_response_time_header(): void
    {
        $middleware = new \App\Middleware\TimingMiddleware();

        $response = $middleware->handle(
            Request::create('/test', 'GET'),
            fn (Request $request) => new Response('OK')
        );

        $this->assertSame(200, $response->getStatusCode());
        $this->assertMatchesRegularExpression('/^\d+(\.\d+)?ms$/', $response->headers->get('X-Response-Time'));
    }

    public function test_params_arrive_as_strings(): void
    {
        $middleware = new \App\Middleware\MaintenanceMiddleware('/nonexistent');

        $response = $middleware->handle(
            Request::create('/test', 'GET'),
            fn (Request $request) => new Response('OK'),
            '600' // as the router would pass 'maintenance:600'
        );

        $this->assertSame(200, $response->getStatusCode());
    }
}