getMethod()); $path = $this->normalizePath($request->getUri()->getPath()); $host = $request->getUri()->getHost(); $routesToMatch = $routes; foreach ($routesToMatch as $route) { $attributes = []; if ($this->isMatch($method, $path, $host, $route, $attributes)) { $attributes = $this->mergeDefaults($route, $attributes); return $this->applyAttributes($route, $attributes); } // i18n support: check alternative paths $routeAttributes = $route->getAttributes(); if (isset($routeAttributes['i18n']) && is_array($routeAttributes['i18n'])) { foreach ($routeAttributes['i18n'] as $lang => $i18nPath) { $normalizedI18nPath = $this->normalizePath($i18nPath); if ($this->isMatch($method, $path, $host, $route, $attributes, $normalizedI18nPath)) { $attributes['lang'] = $lang; $attributes = $this->mergeDefaults($route, $attributes); return $this->applyAttributes($route, $attributes); } } } } // Try to find a fallback return $this->matchFallback($path, $routes); } /** * Attempts to match a fallback handler for the given path. * * @param string $path * @param RouteCollection $routes * @return RouteDefinition|null */ private function matchFallback(string $path, RouteCollection $routes): ?RouteDefinition { $bestFallback = null; $longestPrefix = -1; foreach ($routes as $route) { $attributes = $route->getAttributes(); if (isset($attributes['_fallback'])) { $prefix = $attributes['_fallback_prefix'] ?? ''; if (str_starts_with($path, $prefix) && strlen($prefix) > $longestPrefix) { $longestPrefix = strlen($prefix); $bestFallback = $route; } } } if ($bestFallback) { $attributes = $bestFallback->getAttributes(); return new RouteDefinition( 'FALLBACK', $path, $path, $attributes['_fallback'], null, $bestFallback->getMiddleware() ); } return null; } /** * Merges default values for missing optional parameters. * * @param RouteDefinition $route * @param array $attributes * @return array */ private function mergeDefaults(RouteDefinition $route, array $attributes): array { return array_merge($route->getDefaults(), $attributes); } /** * Determines if a request matches a route definition. * * @param string $method The request method * @param string $path The request path * @param RouteDefinition $route The route to check * @param array $attributes Extracted attributes * @return bool */ private function isMatch(string $method, string $path, string $host, RouteDefinition $route, array &$attributes, ?string $overridePath = null): bool { $routeMethod = strtoupper($route->getMethod()); if ($routeMethod !== $method && $routeMethod !== 'REDIRECT') { return false; } // Subdomain constraint check $routeAttributes = $route->getAttributes(); if (isset($routeAttributes['subdomain'])) { $subdomain = $routeAttributes['subdomain']; if (!str_starts_with($host, $subdomain . '.')) { return false; } } $pattern = $overridePath ? $this->compilePatternFromPath($overridePath, $route) : $this->getPatternForRoute($route); if (preg_match($pattern, $path, $matches)) { $attributes = array_filter($matches, 'is_string', ARRAY_FILTER_USE_KEY); return true; } return false; } /** * @internal */ public function getPatternForRoute(RouteDefinition $route): string { return $this->compilePattern($route); } /** * Compiles a route path into a regex pattern. * * @param RouteDefinition $route * @return string */ private function compilePattern(RouteDefinition $route): string { $id = spl_object_id($route); if (isset($this->compiledPatterns[$id])) { return $this->compiledPatterns[$id]; } $this->compiledPatterns[$id] = $this->compilePatternFromPath($route->getPath(), $route); return $this->compiledPatterns[$id]; } /** * Compiles a specific path into a regex pattern using route's validation and defaults. * * @param string $path * @param RouteDefinition $route * @return string */ private function compilePatternFromPath(string $path, RouteDefinition $route): string { $validation = $route->getValidation(); $defaults = $route->getDefaults(); // Replace {{param?}} and {{param}} with regex $pattern = preg_replace_callback('#/\{\{([a-zA-Z0-9_]+)(\?)?\}\}#', function ($matches) use ($validation, $defaults) { $name = $matches[1]; $optional = (isset($matches[2]) && $matches[2] === '?') || array_key_exists($name, $defaults); $rules = $validation[$name] ?? []; $regex = '[^/]+'; // Validation rules support foreach ((array)$rules as $rule) { if ($rule === 'numeric' || $rule === 'int') { $regex = '[0-9]+'; } elseif ($rule === 'alpha') { $regex = '[a-zA-Z]+'; } elseif ($rule === 'alphanumeric') { $regex = '[a-zA-Z0-9]+'; } elseif (str_starts_with($rule, 'regex:')) { $regex = substr($rule, 6); } } if ($optional) { return '(?:/(?P<' . $name . '>' . $regex . '))?'; } return '/(?P<' . $name . '>' . $regex . ')'; }, $path); $pattern = str_replace('//', '/', $pattern); return '#^' . $pattern . '/?$#'; } /** * Applies extracted attributes to a new route definition. * * @param RouteDefinition $route * @param array $attributes * @return RouteDefinition */ private function applyAttributes(RouteDefinition $route, array $attributes): RouteDefinition { $data = $route->toArray(); $data['attributes'] = array_merge($data['attributes'], $attributes); return new RouteDefinition( $data['method'], $data['pattern'], $data['path'], $data['handler'], $data['name'], $data['middleware'], $data['validation'], $data['defaults'], $data['module'], $data['attributes'] ); } }