0

404 and 405 handling — no route found and method not allowed

Intermediate5 min read·fw-03-007

Concept

404 and 405 handling are the two failure cases when no route matches a request. Handling them correctly is important for API clients that parse HTTP status codes.

404 Not Found: No route matches the URI at all, regardless of HTTP method. The router throws RouteNotFoundException (or returns null). The exception handler converts it to a 404 response.

405 Method Not Allowed: A route exists for the URI but NOT for the requested HTTP method. RFC 7231 requires a 405 response to include an Allow header listing the valid methods. Example: Allow: GET, HEAD, OPTIONS.

Detection algorithm:

  1. Try to find a route matching method + URI.
  2. If found → dispatch normally.
  3. If not found → check if ANY route matches the URI (ignoring method).
  4. If URI matches found → 405 with Allow header listing those methods' methods.
  5. If no URI match → 404.

RouteNotFoundException and MethodNotAllowedException: Custom exception classes. The exception handler has specific renderers for each. MethodNotAllowedException carries the list of allowed methods.

HEAD requests: HEAD behaves like GET but returns only headers (no body). If a route is registered for GET, it implicitly also supports HEAD. The router should check GET routes when matching a HEAD request.

OPTIONS handling: Automatically respond to OPTIONS with the Allow header listing the methods for the requested URI. Some frameworks auto-register this.

Code Example

php
<?php
namespace Framework\Routing;

class RouteNotFoundException extends \RuntimeException
{
    public function __construct(string $uri)
    {
        parent::__construct("No route found for URI: {$uri}", 404);
    }
}

class MethodNotAllowedException extends \RuntimeException
{
    public function __construct(private readonly array $allowedMethods)
    {
        $methods = implode(', ', $allowedMethods);
        parent::__construct("Method Not Allowed. Allowed: {$methods}", 405);
    }

    public function getAllowedMethods(): array
    {
        return $this->allowedMethods;
    }
}

class RouteMatcher
{
    public function match(RouteCollection $routes, string $method, string $uri): MatchedRoute
    {
        $method = strtoupper($method);

        // HEAD is treated like GET
        $lookupMethod = $method === 'HEAD' ? 'GET' : $method;

        // Phase 1: Match on method + URI
        foreach ($routes->getByMethod($lookupMethod) as $route) {
            $params = $this->tryMatch($route, $uri);
            if ($params !== null) {
                return new MatchedRoute($route, $params);
            }
        }

        // Phase 2: Find routes with matching URI (any method) for 405 vs 404
        $allowedMethods = [];
        foreach ($routes->all() as $route) {
            if ($this->tryMatch($route, $uri) !== null) {
                $allowedMethods[] = $route->method;
                if ($route->method === 'GET') {
                    $allowedMethods[] = 'HEAD'; // GET implies HEAD
                }
            }
        }

        // OPTIONS — auto-respond
        if ($method === 'OPTIONS' && !empty($allowedMethods)) {
            throw new AutoOptionsException(array_unique($allowedMethods));
        }

        if (!empty($allowedMethods)) {
            throw new MethodNotAllowedException(array_unique($allowedMethods));
        }

        throw new RouteNotFoundException($uri);
    }

    private function tryMatch(Route $route, string $uri): ?array
    {
        if (preg_match($route->getCompiledPattern(), $uri, $matches) !== 1) {
            return null;
        }
        return array_filter($matches, fn($k) => is_string($k), ARRAY_FILTER_USE_KEY);
    }
}

// Exception handler — convert to HTTP response
class ExceptionHandler
{
    public function render(\Throwable $e): \Framework\Http\Response
    {
        return match(true) {
            $e instanceof RouteNotFoundException    => new \Framework\Http\Response('Not Found', 404),
            $e instanceof MethodNotAllowedException => new \Framework\Http\Response(
                'Method Not Allowed', 405,
                ['Allow' => implode(', ', $e->getAllowedMethods())]
            ),
            default => new \Framework\Http\Response('Internal Server Error', 500),
        };
    }
}