'[0-9]+']` to match numbers). * - A function that validates the parameter * (e.g., `['id' => is_numeric(...)]`). * * @return void */ public static function match(string $methods, string $uri, callable $controller, ?callable $middleware = null, array $expression = []): void { $methods = explode('|', strtoupper($methods)); $middlewareStack = self::$middlewareStack; if ($middleware) { $middlewareStack['before'][] = $middleware; } $segments = preg_replace('/\{(\w+)(\?|(\*))?\}/', '*', $uri); $segments = preg_split('/(?=\/)/', self::$routeGroupPrefix . $segments, -1, PREG_SPLIT_NO_EMPTY); $currentNode = &self::$routeTree; foreach ($segments as $segment) { $currentNode[$segment] ??= []; $currentNode = &$currentNode[$segment]; } $currentNode[1337][] = [ 'uri' => self::$routeGroupPrefix . $uri, 'methods' => $methods, 'controller' => $controller, 'middleware' => $middlewareStack, 'expression' => self::$expressionStack + $expression, ]; } /** * Shorthand for `Route2::match('GET', ...)` */ public static function get(string $uri, callable $controller, ?callable $middleware = null, array $expression = []): void { self::match('GET', $uri, $controller, $middleware, $expression); } /** * Shorthand for `Route2::match('POST', ...)` */ public static function post(string $uri, callable $controller, ?callable $middleware = null, array $expression = []): void { self::match('POST', $uri, $controller, $middleware, $expression); } /** * Shorthand for `Route2::match('PUT', ...)` */ public static function put(string $uri, callable $controller, ?callable $middleware = null, array $expression = []): void { self::match('PUT', $uri, $controller, $middleware, $expression); } /** * Shorthand for `Route2::match('DELETE', ...)` */ public static function delete(string $uri, callable $controller, ?callable $middleware = null, array $expression = []): void { self::match('DELETE', $uri, $controller, $middleware, $expression); } /** * Shorthand for `Route2::match('PATCH', ...)` */ public static function patch(string $uri, callable $controller, ?callable $middleware = null, array $expression = []): void { self::match('PATCH', $uri, $controller, $middleware, $expression); } /** * Shorthand for `Route2::match('OPTIONS', ...)` */ public static function options(string $uri, callable $controller, ?callable $middleware = null, array $expression = []): void { self::match('OPTIONS', $uri, $controller, $middleware, $expression); } /** * Shorthand for `Route2::match('GET|POST', ...)` */ public static function form(string $uri, callable $controller, ?callable $middleware = null, array $expression = []): void { self::match('GET|POST', $uri, $controller, $middleware, $expression); } /** * Shorthand for `Route2::match('GET|POST|PUT|DELETE|PATCH|OPTIONS', ...)` */ public static function any(string $uri, callable $controller, ?callable $middleware = null, array $expression = []): void { self::match('GET|POST|PUT|DELETE|PATCH|OPTIONS', $uri, $controller, $middleware, $expression); } /** * Adds a middleware to the "before" middleware stack. * * Routes added after this method call will inherit the middleware. * * @param callable $middleware The middleware to add to the "before" stack. * It should be a callable function or method. * * @return void */ public static function before(callable $middleware): void { self::$middlewareStack['before'][] = $middleware; } /** * Adds a middleware to the "after" middleware stack. * * Routes added after this method call will inherit the middleware. * * @param callable $middleware The middleware to add to the "after" stack. * It should be a callable function or method. * * @return void */ public static function after(callable $middleware): void { self::$middlewareStack['after'][] = $middleware; } /** * Adds expressions to the expression stack. * * Routes added after this method call will inherit the expression constraints. * * @param array $expressions An associative array where: * - The key is the parameter name (e.g., 'id'). * - The value is either: * - A regex string (e.g., `['id' => '[0-9]+']`) to match specific patterns. * - A callable function that validates the parameter * (e.g., `['id' => is_numeric(...)]`). * * @throws InvalidArgumentException If an expression is neither a string nor a callable. * @return void */ public static function expression(array $expression): void { foreach ($expression as $param => $regex) { if (!is_string($regex) && !is_callable($regex)) { throw new InvalidArgumentException("Expression for parameter '{$param}' must be a regex string or a callable function"); } } self::$expressionStack[] = $expression; } /** * Share attributes across multiple routes * * Preserves and restores the previous state after the callback. * * @param string|null $prefix Optional prefix to be applied to all routes in the group. * @param callable|null $callback A callback function that defines the routes within the group. Required. * * @return void */ public static function group(?string $prefix = null, ?callable $callback = null): void { $previousPrefix = self::$routeGroupPrefix; $previousMiddlewareStack = self::$middlewareStack; $previousExpressionStack = self::$expressionStack; self::$routeGroupPrefix .= $prefix ?? ''; $callback(); self::$routeGroupPrefix = $previousPrefix; self::$middlewareStack = $previousMiddlewareStack; self::$expressionStack = $previousExpressionStack; } /** * Gets the relative URI of the current HTTP request. * * Example: * - `/index.php/myroute` → `/myroute` * * @return string Relative request path. */ public static function getRelativeRequestUri(): string { $requestUri = $_SERVER['REQUEST_URI']; $scriptName = $_SERVER['SCRIPT_NAME']; $scriptDir = dirname($scriptName) . '/'; if (str_starts_with($requestUri, $scriptName)) { $requestUri = substr($requestUri, strlen($scriptName)); } if ($requestUri === $scriptDir) { $requestUri = '/'; } if ($requestUri === '') { $requestUri = '/'; } return $requestUri; } /** * Matches a route in the tree structure based on the URI. * * @param string $requestUri The URI to match against the routes. * * @return array Returns the matched route details or an empty array if no match is found. */ private static function matchRoute(string $requestUri): array { $segments = preg_split('/(?=\/)/', $requestUri, -1, PREG_SPLIT_NO_EMPTY); return self::matchRouteRecursive(self::$routeTree, $segments); } /** * Recursively matches a route in the tree structure. * * @param array $tree The current level of the route tree. * @param array $segments The remaining URI segments to match. * @param bool $wildcard Indicates if a wildcard match is active. * * @return array Returns the matched route details or an empty array if no match is found. */ private static function matchRouteRecursive(array $tree, array $segments, bool $wildcard = false): array { if (!$segments) { return $tree[1337] ?? []; } $currentSegment = array_shift($segments); foreach ([$currentSegment, '/*'] as $key) { if (isset($tree[$key])) { $match = self::matchRouteRecursive($tree[$key], $segments, $key === '/*'); if ($match) { return $match; } } } return $wildcard ? ($tree[1337] ?? []) : []; } /** * Dispatches the request to the appropriate route. * * @param string|null $method The HTTP method to match against the routes. If null, uses the current request method. * @param string|null $uri The URI to match against the routes. If null, uses the current relative request URI. * * @return bool True if a route was matched and dispatched, false otherwise. */ public static function dispatch(?string $method = null, ?string $uri = null): bool { $method = strtoupper($method ?? $_SERVER['REQUEST_METHOD']); $uri = rawurldecode($uri = strtok($uri ?? self::getRelativeRequestUri(), '?')); $routes = self::matchRoute($uri); foreach ($routes as $route) { if (!in_array($method, $route['methods'])) { continue; } $pattern = preg_replace( ['/\{(\w+)\}/', '/\{(\w+)\?\}/', '/\{(\w+)\*\}/'], ['(?P<$1>[^/]+)', '(?P<$1>[^/]*)', '(?P<$1>.*)'], $route['uri'] ); $pattern = '#^' . $pattern . '$#'; if (!preg_match($pattern, $uri, $matches)) { continue; } foreach ($matches as $key => $value) { if (is_string($key) && !empty($value)) { $params[$key] = $value; } } foreach ($route['expression'] as $param => $expression) { if (!isset($params[$param])) { continue; } if (is_callable($expression) && !$expression($params[$param])) { continue 2; } if (is_string($expression) && !preg_match('#^' . $expression . '$#', $params[$param])) { continue 2; } if (!is_string($expression) && !is_callable($expression)) { throw new InvalidArgumentException( "Expression for parameter '{$param}' must be a regex string or callable function" ); } } foreach ($route['middleware']['before'] ?? [] as $middleware) { $middleware(); } $route['controller'](...$params ?? []); foreach ($route['middleware']['after'] ?? [] as $middleware) { $middleware(); } return true; } return false; } }