> */ private array $routes = []; // [method][path] => Route private bool $initialized = false; private string $cacheFile; public function __construct( private readonly LoggerInterface $logger, private readonly ModuleManager $moduleManager, Container $container, #[Inject('rootDir')] private readonly string $rootDir, #[Inject('moduleDir')] private readonly string $moduleDir, #[Inject('environment')] private readonly string $environment ) { $this->container = $container; $this->cacheFile = $rootDir . '/var/cache/routes.cache.php'; } private function initialize(): void { // load cached routes in production if ($this->environment === 'prod' && file_exists($this->cacheFile)) { $data = include $this->cacheFile; if (is_array($data)) { $this->routes = $data; $this->initialized = true; return; } } // otherwise scan for routes $this->scan(); $this->initialized = true; // write cache $dir = dirname($this->cacheFile); if (!is_dir($dir)) @mkdir($dir, 0775, true); file_put_contents($this->cacheFile, 'routes, true) . ';'); } private function scan(): void { // load core controllers foreach (glob($this->rootDir . '/core/lib/Controllers/*.php') as $file) { $this->extract($file); } // load module controllers foreach ($this->moduleManager->list(true, true) as $module) { $path = $this->moduleDir . '/' . $module->handle() . '/lib/Controllers'; if (is_dir($path)) { foreach (glob($path . '/*.php') as $file) { $this->extract($file, '/m/' . $module->handle()); } } } } private function extract(string $file, string $routePrefix = ''): void { $contents = file_get_contents($file); if ($contents === false) return; // extract namespace if (!preg_match('#namespace\\s+([^;]+);#', $contents, $nsM)) return; $ns = trim($nsM[1]); // extract class names if (!preg_match_all('#class\\s+(\\w+)#', $contents, $cM)) return; foreach ($cM[1] as $class) { $fqcn = $ns . '\\' . $class; try { if (!class_exists($fqcn)) { continue; } require_once $file; $reflectionClass = new ReflectionClass($fqcn); if ($reflectionClass->isAbstract()) continue; foreach ($reflectionClass->getMethods(ReflectionMethod::IS_PUBLIC) as $reflectionMethod) { $attributes = array_merge( $reflectionMethod->getAttributes(AnonymousRoute::class), $reflectionMethod->getAttributes(AuthenticatedRoute::class) ); foreach ($attributes as $attribute) { $route = $attribute->newInstance(); $httpPath = $routePrefix . $route->path; foreach ($route->methods as $httpMethod) { $this->routes[$httpMethod][$httpPath] = new Route( method: $httpMethod, path: $httpPath, name: $route->name, authenticated: $route instanceof AuthenticatedRoute, className: $reflectionClass->getName(), classMethodName: $reflectionMethod->getName(), classMethodParameters: $reflectionMethod->getParameters(), ); } } } } catch (\Throwable $e) { $this->logger->error('Route collection failed', ['file' => $file, 'error' => $e->getMessage()]); } } } /** * Match a Request to a Route, or return null if no match. * Supports exact matches and simple {param} patterns. * Prioritizes: 1) exact matches, 2) specific patterns, 3) catch-all patterns */ public function match(Request $request): ?Route { if (!$this->initialized) { $this->initialize(); } $method = $request->getMethod(); $path = $request->getPathInfo(); // Exact match first if (isset($this->routes[$method][$path])) { return $this->routes[$method][$path]; } // Pattern matching - separate catch-all from specific patterns $specificPatterns = []; $catchAllPattern = null; foreach ($this->routes[$method] ?? [] as $routePath => $routeObj) { if (str_contains($routePath, '{')) { // Check if this is a catch-all pattern (e.g., /{path}) if (preg_match('#^/\{[^/]+\}$#', $routePath)) { $catchAllPattern = [$routePath, $routeObj]; } else { $specificPatterns[] = [$routePath, $routeObj]; } } } // Try specific patterns first foreach ($specificPatterns as [$routePath, $routeObj]) { $pattern = preg_replace('#\{([a-zA-Z_][a-zA-Z0-9_]*)\}#', '(?P<$1>[^/]+)', $routePath); $pattern = '#^' . $pattern . '$#'; if (preg_match($pattern, $path, $m)) { $params = []; foreach ($m as $k => $v) { if (is_string($k)) { $params[$k] = $v; } } return $routeObj->withParams($params); } } // Try catch-all pattern last if ($catchAllPattern !== null) { [$routePath, $routeObj] = $catchAllPattern; $pattern = preg_replace('#\{([a-zA-Z_][a-zA-Z0-9_]*)\}#', '(?P<$1>.*)', $routePath); $pattern = '#^' . $pattern . '$#'; if (preg_match($pattern, $path, $m)) { $params = []; foreach ($m as $k => $v) { if (is_string($k)) { $params[$k] = $v; } } return $routeObj->withParams($params); } } return null; } /** * Dispatch a matched route meta and return a Response (or null if controller does not return one). * Performs light argument resolution: Request object, route params, body fields, full body for array params. */ public function dispatch(Route $route, Request $request): ?Response { // extract controller and method $routeControllerName = $route->className; $routeControllerMethod = $route->classMethodName; $routeControllerParameters = $route->classMethodParameters; // instantiate controller if ($this->container->has($routeControllerName)) { $instance = $this->container->get($routeControllerName); } else { $instance = new $routeControllerName(); } try { $requestParameters = $request->getPayload(); } catch (\Throwable) { // ignore payload errors } $reflectionMethod = new \ReflectionMethod($routeControllerName, $routeControllerMethod); $routeParams = $route->params ?? []; $callArgs = []; foreach ($reflectionMethod->getParameters() as $reflectionParameter) { $reflectionParameterName = $reflectionParameter->getName(); $reflectionParameterType = $reflectionParameter->getType(); // if parameter matches request class, use current request if ($reflectionParameterType && $reflectionParameterType instanceof \ReflectionNamedType && !$reflectionParameterType->isBuiltin() && is_a($reflectionParameterType->getName(), Request::class, true)) { $callArgs[] = $request; continue; } // if method parameter matches a route path param, use that (highest priority) if (array_key_exists($reflectionParameterName, $routeParams)) { $callArgs[] = $routeParams[$reflectionParameterName]; continue; } // if method parameter matches a request param, use that if ($requestParameters->has($reflectionParameterName)) { // if parameter is a class implementing JsonDeserializable, call jsonDeserialize on it if ($reflectionParameterType && $reflectionParameterType instanceof \ReflectionNamedType && !$reflectionParameterType->isBuiltin() && is_a($reflectionParameterType->getName(), JsonDeserializable::class, true)) { $type = $reflectionParameterType->getName(); $object = new $type(); if ($object instanceof JsonDeserializable) { $object->jsonDeserialize($requestParameters->get($reflectionParameterName)); $callArgs[] = $object; continue; } } // otherwise, use the raw value $callArgs[] = $requestParameters->get($reflectionParameterName); continue; } // if method parameter did not match, but has a default value, use that if ($reflectionParameter->isDefaultValueAvailable()) { $callArgs[] = $reflectionParameter->getDefaultValue(); continue; } $callArgs[] = null; } $result = $instance->$routeControllerMethod(...$callArgs); return $result instanceof Response ? $result : null; } }