From ebacb8a2e14da1ce0fdf00ea782581a5acf99c76 Mon Sep 17 00:00:00 2001 From: Arif Hoque Date: Tue, 17 Feb 2026 17:20:10 +0600 Subject: [PATCH 1/2] Domain-Restricted Route Matching --- src/Phaseolies/Support/Router.php | 141 +++++++++++++++--- src/Phaseolies/Utilities/Attributes/Route.php | 3 +- 2 files changed, 122 insertions(+), 22 deletions(-) diff --git a/src/Phaseolies/Support/Router.php b/src/Phaseolies/Support/Router.php index 5e01f796..3052cd20 100644 --- a/src/Phaseolies/Support/Router.php +++ b/src/Phaseolies/Support/Router.php @@ -141,9 +141,13 @@ protected function getCacheableRoutes(): array $cacheableRoutes = []; foreach (self::$routes as $method => $routes) { - foreach ($routes as $path => $callback) { - if ($this->isCacheableRoute($callback)) { - $cacheableRoutes[$method][$path] = $callback; + foreach ($routes as $path => $entry) { + ['callback' => $callback, 'domain' => $domain] = $this->unwrapRouteEntry($entry); + + if ($this->isCacheableRoute($entry)) { + $cacheableRoutes[$method][$path] = $domain + ? ['__callback' => $callback, '__domain' => $domain] + : $callback; } } } @@ -159,6 +163,10 @@ protected function getCacheableRoutes(): array */ protected function isCacheableRoute($callback): bool { + if (is_array($callback) && isset($callback['__callback'], $callback['__domain'])) { + $callback = $callback['__callback']; + } + if ( is_array($callback) && count($callback) === 2 && @@ -369,9 +377,10 @@ protected function registerAttributeRoute(string $controllerClass, string $metho $middleware = $route->middleware ?? []; $rateLimit = $route->rateLimit ?? null; $rateLimitDecay = $route->rateLimitDecay ?? 1; + $domain = $route->domain ?? null; foreach ($httpMethods as $httpMethod) { - $this->addRouteNameToAttributesRouting($httpMethod, $path, [$controllerClass, $method], $name); + $this->addRouteNameToAttributesRouting($httpMethod, $path, [$controllerClass, $method], $name, $domain); if (!empty($middleware)) { $this->middleware($middleware); } @@ -389,11 +398,12 @@ protected function registerAttributeRoute(string $controllerClass, string $metho * @param string $path * @param callable|array $callback * @param string|null $name + * @param string|null $domain * @return self */ - protected function addRouteNameToAttributesRouting(string $method, string $path, $callback, ?string $name = null): self + protected function addRouteNameToAttributesRouting(string $method, string $path, $callback, ?string $name = null, ?string $domain = null): self { - $this->addRoute($method, $path, $callback); + $this->addRoute($method, $path, $callback, $domain); if ($name) { self::$namedRoutes[$name] = $this->currentRoutePath; @@ -542,15 +552,31 @@ public function redirect(string $uri, string $destination, int $status = 302): s ); } + /** + * Unwrap the route entry, separating the callback from optional domain metadata. + * + * @param mixed $entry + * @return array{callback: mixed, domain: string|null} + */ + protected function unwrapRouteEntry(mixed $entry): array + { + if (is_array($entry) && isset($entry['__callback'], $entry['__domain'])) { + return ['callback' => $entry['__callback'], 'domain' => $entry['__domain']]; + } + + return ['callback' => $entry, 'domain' => null]; + } + /** * Add a route with group attributes applied. * * @param string $method * @param string $path * @param callable|array $callback + * @param string|null $domain * @return self */ - protected function addRoute(string $method, string $path, $callback): self + protected function addRoute(string $method, string $path, $callback, ?string $domain = null): self { $this->failFastOnBadRouteDefinition($callback); @@ -570,15 +596,12 @@ protected function addRoute(string $method, string $path, $callback): self : $fullPath; } - // Special handling for root within a group - if ($path === '' && $prefix !== '') { - self::$routes[$method][$fullPath] = $callback; - $this->currentRoutePath = $fullPath; - } else { - self::$routes[$method][$fullPath] = $callback; - $this->currentRoutePath = $fullPath; - } + $entry = $domain + ? ['__callback' => $callback, '__domain' => $domain] + : $callback; + self::$routes[$method][$fullPath] = $entry; + $this->currentRoutePath = $fullPath; $this->currentRequestMethod = $method; if (!static::$cacheLoaded) { @@ -640,6 +663,31 @@ public function name(string $name): self return $this; } + /** + * Restricts the last registered route to a specific domain. + * Supports exact domains ('api.example.com'), wildcard subdomains + * ('{tenant}.example.com'), and universal wildcard ('*'). + * + * @param string $domain The domain pattern to restrict to. + * @return self + */ + public function domain(string $domain): self + { + if ($this->currentRoutePath) { + $method = $this->getCurrentRequestMethod(); + $entry = self::$routes[$method][$this->currentRoutePath]; + + ['callback' => $callback] = $this->unwrapRouteEntry($entry); + + self::$routes[$method][$this->currentRoutePath] = [ + '__callback' => $callback, + '__domain' => $domain, + ]; + } + + return $this; + } + /** * Generates a URL for a named route. * @@ -709,25 +757,32 @@ protected function getCurrentRequestMethod(): string public function getCallback($request): mixed { $method = $request->getMethod(); - $url = $request->getPath(); + $url = $request->getPath(); + $host = $request->getHost(); $url = ($url !== '/') ? rtrim($url, '/') : $url; $routes = self::$routes[$method] ?? []; - if (isset($routes[$url])) { - return $routes[$url]; - } + foreach ($routes as $route => $entry) { + ['callback' => $callback, 'domain' => $domain] = $this->unwrapRouteEntry($entry); - foreach ($routes as $route => $callback) { - if ($route === $url) { + // Domain guard — skip routes whose domain pattern doesn't match the request host + if ($domain !== null && !$this->matchesDomain($host, $domain, $request)) { continue; } + // Exact match + if ($route === $url) { + return $callback; + } + + // Catch-all wildcard if ($route === '(.*)') { return $callback; } + // Pattern match with named parameters $routeRegex = $this->convertRouteToRegex($route); if (preg_match($routeRegex, $url, $matches)) { @@ -742,6 +797,50 @@ public function getCallback($request): mixed return false; } + /** + * Match the incoming host against a route domain pattern. + * Supports: + * - Exact domain: 'api.example.com' + * - Port-qualified domain: 'localhost:8000' + * - Wildcard subdomain: '{tenant}.example.com' (injects param into route params) + * - Universal wildcard: '*' + * + * @param string $host + * @param string $domain + * @param mixed $request + * @return bool + */ + protected function matchesDomain(string $host, string $domain, $request): bool + { + $host = strtolower($host); + + // Universal wildcard: match any host + if ($domain === '*') { + return true; + } + + // Named subdomain wildcard: {subdomain}.example.com + if (preg_match('/^\{(\w+)\}\.(.+)$/', $domain, $m)) { + // Strip port from host before matching the suffix + $bareHost = explode(':', $host)[0]; + $domainSuffix = strtolower($m[2]); + $pattern = '/^([^.]+)\.' . preg_quote($domainSuffix, '/') . '$/'; + + if (preg_match($pattern, $bareHost, $hostMatch)) { + // Inject the captured subdomain segment as a route parameter + $existing = $request->getRouteParams(); + $existing[$m[1]] = $hostMatch[1]; + $request->setRouteParams($existing); + return true; + } + + return false; + } + + // Exact match — intentionally port-aware so 'localhost:8000' != 'localhost' + return $host === strtolower($domain); + } + /** * Convert route to regex * diff --git a/src/Phaseolies/Utilities/Attributes/Route.php b/src/Phaseolies/Utilities/Attributes/Route.php index 0133f107..9c738bb3 100644 --- a/src/Phaseolies/Utilities/Attributes/Route.php +++ b/src/Phaseolies/Utilities/Attributes/Route.php @@ -13,7 +13,8 @@ public function __construct( public ?string $name = null, public array $middleware = [], public ?int $rateLimit = null, - public ?int $rateLimitDecay = 1 + public ?int $rateLimitDecay = 1, + public ?string $domain = null ) { if (is_string($this->methods)) { $this->methods = [$this->methods]; From bba96bb4fdec50c3619da8e5d4d149552936cf9b Mon Sep 17 00:00:00 2001 From: Arif Hoque Date: Tue, 17 Feb 2026 17:52:33 +0600 Subject: [PATCH 2/2] moved to match domain function: --- src/Phaseolies/Support/Router.php | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/Phaseolies/Support/Router.php b/src/Phaseolies/Support/Router.php index 3052cd20..26d1f3ad 100644 --- a/src/Phaseolies/Support/Router.php +++ b/src/Phaseolies/Support/Router.php @@ -758,7 +758,6 @@ public function getCallback($request): mixed { $method = $request->getMethod(); $url = $request->getPath(); - $host = $request->getHost(); $url = ($url !== '/') ? rtrim($url, '/') : $url; @@ -768,7 +767,7 @@ public function getCallback($request): mixed ['callback' => $callback, 'domain' => $domain] = $this->unwrapRouteEntry($entry); // Domain guard — skip routes whose domain pattern doesn't match the request host - if ($domain !== null && !$this->matchesDomain($host, $domain, $request)) { + if ($domain !== null && !$this->matchesDomain($domain, $request)) { continue; } @@ -805,13 +804,13 @@ public function getCallback($request): mixed * - Wildcard subdomain: '{tenant}.example.com' (injects param into route params) * - Universal wildcard: '*' * - * @param string $host * @param string $domain * @param mixed $request * @return bool */ - protected function matchesDomain(string $host, string $domain, $request): bool + protected function matchesDomain(string $domain, $request): bool { + $host = $request->getHost(); $host = strtolower($host); // Universal wildcard: match any host