404 and 405 handling — no route found and method not allowed
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:
- Try to find a route matching method + URI.
- If found → dispatch normally.
- If not found → check if ANY route matches the URI (ignoring method).
- If URI matches found → 405 with
Allowheader listing those methods' methods. - 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
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),
};
}
}