Router.php
PHP
Path: src/Controller/Router.php
<?php
namespace mini\Controller;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Message\ResponseInterface;
/**
* Controller-level router with type-aware route registration
*
* Provides route matching and parameter extraction for controller methods.
* Not PSR-15 middleware - returns match information for AbstractController to handle.
*
* Type-aware pattern generation:
* - int $id → {id} becomes (?<id>\d+)
* - string $slug → {slug} becomes (?<slug>[^/]+)
* - float $price → {price} becomes (?<price>\d+\.?\d*)
*
* Architecture:
* 1. Router::match() finds matching route and extracts URL parameters
* 2. Returns array with 'handler' callable and 'params' (type-cast)
* 3. AbstractController enriches request with params and creates ConverterHandler
* 4. ConverterHandler invokes controller method and converts return value
*/
class Router
{
private array $routes = [];
public function __construct()
{
}
/**
* Register GET route
*
* @param string $path Route pattern (e.g., '/', '/{id}/', '/{postId}/comments/{commentId}/')
* @param \Closure $handler Controller method closure
*/
public function get(string $path, \Closure $handler): void
{
$this->addRoute('GET', $path, $handler);
}
/**
* Register POST route
*/
public function post(string $path, \Closure $handler): void
{
$this->addRoute('POST', $path, $handler);
}
/**
* Register PATCH route
*/
public function patch(string $path, \Closure $handler): void
{
$this->addRoute('PATCH', $path, $handler);
}
/**
* Register PUT route
*/
public function put(string $path, \Closure $handler): void
{
$this->addRoute('PUT', $path, $handler);
}
/**
* Register DELETE route
*/
public function delete(string $path, \Closure $handler): void
{
$this->addRoute('DELETE', $path, $handler);
}
/**
* Register route for any method
*/
public function any(string $path, \Closure $handler): void
{
$this->addRoute('*', $path, $handler);
}
/**
* Import routes from controller method attributes
*
* Scans the provided object for methods with Route attributes
* (#[GET], #[POST], #[Route], etc.) and automatically registers them.
*
* Example:
* ```php
* class UserController extends AbstractController {
* public function __construct() {
* parent::__construct();
* $this->router->importRoutesFromAttributes($this);
* }
*
* #[GET('/')]
* public function index(): ResponseInterface { ... }
*
* #[POST('/')]
* public function create(): ResponseInterface { ... }
* }
* ```
*
* @param object $controller Controller instance to scan for route attributes
*/
public function importRoutesFromAttributes(object $controller): void
{
$reflection = new \ReflectionClass($controller);
foreach ($reflection->getMethods(\ReflectionMethod::IS_PUBLIC) as $method) {
// Skip constructor and other special methods
if ($method->isConstructor() || $method->isDestructor() || $method->isStatic()) {
continue;
}
// Get Route attributes (includes GET, POST, etc. since they extend Route)
$attributes = $method->getAttributes(
Attributes\Route::class,
\ReflectionAttribute::IS_INSTANCEOF
);
foreach ($attributes as $attribute) {
/** @var Attributes\Route $route */
$route = $attribute->newInstance();
// Create closure for this method
$closure = $method->getClosure($controller);
// Determine HTTP method
$httpMethod = $route->method ?? '*';
// Register the route
$this->addRoute($httpMethod, $route->path, $closure);
}
}
}
/**
* Add route to registry
*/
private function addRoute(string $method, string $path, \Closure $handler): void
{
// Analyze handler to determine parameter types
$reflection = $this->reflectHandler($handler);
// Compile path pattern with type-aware regex
$pattern = $this->compilePath($path, $reflection);
$this->routes[] = [
'method' => $method,
'path' => $path,
'pattern' => $pattern,
'handler' => $handler,
'reflection' => $reflection
];
}
/**
* Reflect on handler to extract parameter information
*/
private function reflectHandler(\Closure $handler): array
{
$reflection = new \ReflectionFunction($handler);
$params = [];
foreach ($reflection->getParameters() as $param) {
$name = $param->getName();
$type = $param->getType();
$params[$name] = [
'name' => $name,
'type' => $type ? $type->getName() : 'string',
'nullable' => $type && $type->allowsNull(),
'hasDefault' => $param->isDefaultValueAvailable(),
'default' => $param->isDefaultValueAvailable() ? $param->getDefaultValue() : null
];
}
return $params;
}
/**
* Compile path pattern with type-aware regex
*
* Analyzes handler parameters to determine regex patterns:
* - int → \d+
* - string → [^/]+
* - float → \d+\.?\d*
* - bool → [01]|true|false
*
* Examples:
* '/{id}/' + int $id → '/(?<id>\d+)/'
* '/{slug}/' + string $slug → '/(?<slug>[^/]+)/'
* '/{postId}/comments/{commentId}/' + int params → '/(?<postId>\d+)/comments/(?<commentId>\d+)/'
*/
private function compilePath(string $path, array $params): string
{
$pattern = preg_replace_callback(
'/\{(\w+)\}/',
function($matches) use ($params) {
$paramName = $matches[1];
// Determine regex based on parameter type
if (isset($params[$paramName])) {
$regex = match($params[$paramName]['type']) {
'int' => '\d+',
'float' => '\d+\.?\d*',
'bool' => '[01]|true|false',
default => '[^/]+'
};
} else {
// Parameter not found in handler - default to string
$regex = '[^/]+';
}
return "(?<{$paramName}>{$regex})";
},
$path
);
return '#^' . $pattern . '$#';
}
/**
* Match request to a registered route
*
* Returns route match information including handler callable and type-cast parameters.
* Handles trailing slash redirects by returning ResponseInterface for redirects.
*
* @param ServerRequestInterface $request
* @return array|ResponseInterface Array with 'handler' and 'params', or redirect Response
* @throws \mini\Exceptions\NotFoundException If no route matches
*/
public function match(ServerRequestInterface $request): array|ResponseInterface
{
$method = $request->getMethod();
$path = parse_url($request->getRequestTarget(), PHP_URL_PATH) ?? '/';
// Preserve query string for redirects
$query = $request->getUri()->getQuery();
$queryString = $query ? '?' . $query : '';
// Try to find matching route
foreach ($this->routes as $route) {
// Check HTTP method
if ($route['method'] !== '*' && $route['method'] !== $method) {
continue;
}
// Check path pattern
if (preg_match($route['pattern'], $path, $matches)) {
// Extract named parameters
$rawParams = array_filter($matches, 'is_string', ARRAY_FILTER_USE_KEY);
// Enforce trailing slash consistency
$routeEndsWithSlash = str_ends_with($route['path'], '/');
$pathEndsWithSlash = str_ends_with($path, '/');
if ($routeEndsWithSlash && !$pathEndsWithSlash) {
// Route expects trailing slash but path doesn't have it - redirect
return new \mini\Http\Message\Response('', ['Location' => $path . '/' . $queryString], 301);
} elseif (!$routeEndsWithSlash && $pathEndsWithSlash && $path !== '/') {
// Route doesn't expect trailing slash but path has it - redirect
return new \mini\Http\Message\Response('', ['Location' => rtrim($path, '/') . $queryString], 301);
}
// Type-cast parameters
$params = $this->typeCastParams($route, $rawParams);
// Return match information
return [
'handler' => $route['handler'],
'params' => $params,
];
}
}
// No route matched - check if alternate path (with/without trailing slash) would match
if (!str_ends_with($path, '/')) {
// Try with trailing slash
foreach ($this->routes as $route) {
if ($route['method'] !== '*' && $route['method'] !== $method) {
continue;
}
if (preg_match($route['pattern'], $path . '/', $matches)) {
// Alternate path matches - redirect to it
return new \mini\Http\Message\Response('', ['Location' => $path . '/' . $queryString], 301);
}
}
} elseif ($path !== '/') {
// Try without trailing slash
$pathWithoutSlash = rtrim($path, '/');
foreach ($this->routes as $route) {
if ($route['method'] !== '*' && $route['method'] !== $method) {
continue;
}
if (preg_match($route['pattern'], $pathWithoutSlash, $matches)) {
// Alternate path matches - redirect to it
return new \mini\Http\Message\Response('', ['Location' => $pathWithoutSlash . $queryString], 301);
}
}
}
// No route matched - 404
throw new \mini\Exceptions\NotFoundException('Route not found');
}
/**
* Type-cast URL parameters based on route reflection info
*
* @param array $route Matched route information
* @param array $rawParams Raw URL parameters from regex match
* @return array Type-cast parameters
*/
private function typeCastParams(array $route, array $rawParams): array
{
$params = [];
// Type-cast parameters based on reflection info
foreach ($route['reflection'] as $paramName => $paramInfo) {
if (!isset($rawParams[$paramName])) {
continue;
}
$value = $rawParams[$paramName];
// Type cast based on parameter type
$params[$paramName] = match($paramInfo['type']) {
'int' => (int)$value,
'float' => (float)$value,
'bool' => filter_var($value, FILTER_VALIDATE_BOOLEAN),
default => $value
};
}
return $params;
}
}