From 0bfc1e9eabd39d5b875ec2f60b848ae16eb8bd0f Mon Sep 17 00:00:00 2001 From: techmahedy Date: Thu, 12 Nov 2026 12:25:28 +0600 Subject: [PATCH 1/2] perf: optimize request routing and dependency resolution --- src/Phaseolies/Application.php | 2 +- src/Phaseolies/DI/Container.php | 105 ++++++++++++++++++++-- src/Phaseolies/Http/Request.php | 2 +- src/Phaseolies/Support/Router.php | 140 ++++++++++++++++++++++++++++-- tests/Router/RouterTest.php | 23 +++++ 5 files changed, 257 insertions(+), 15 deletions(-) diff --git a/src/Phaseolies/Application.php b/src/Phaseolies/Application.php index 551cd5a7..d650d091 100644 --- a/src/Phaseolies/Application.php +++ b/src/Phaseolies/Application.php @@ -1395,7 +1395,7 @@ protected function reportDeferFailure(\Throwable $failure, ?\Throwable $original */ protected function cleanupRequestScopedServices(): void { - if ($this->has('auth')) { + if ($this->hasInstance('auth')) { $auth = $this->make('auth'); if ($auth instanceof ActorManager) { diff --git a/src/Phaseolies/DI/Container.php b/src/Phaseolies/DI/Container.php index f939d71a..e3fb4dc0 100644 --- a/src/Phaseolies/DI/Container.php +++ b/src/Phaseolies/DI/Container.php @@ -46,6 +46,13 @@ class Container implements ArrayAccess */ private array $resolving = []; + /** + * Cached reflection metadata for concrete classes. + * + * @var array}> + */ + private array $buildMetadata = []; + /** * Whether each class seen by freezeResolved() is a frozen service. * @@ -384,19 +391,18 @@ public function build(string $concrete, array $parameters = []): object return new $concrete(...$parameters); } - $reflector = new \ReflectionClass($concrete); + $metadata = $this->buildMetadata[$concrete] ??= $this->inspectBuildMetadata($concrete); + $reflector = $metadata['reflector']; if (!$reflector->isInstantiable()) { throw new \RuntimeException("Target [{$concrete}] is not instantiable"); } - $constructor = $reflector->getConstructor(); - - if (is_null($constructor)) { + if ($metadata['parameters'] === []) { $instance = new $concrete(...$parameters); } else { - $dependencies = $this->resolveDependencies( - $constructor->getParameters(), + $dependencies = $this->resolveCachedDependencies( + $metadata['parameters'], $parameters, $concrete ); @@ -408,6 +414,36 @@ public function build(string $concrete, array $parameters = []): object return $instance; } + /** + * Inspect a concrete class once and reuse the metadata for future builds. + * + * @param class-string $concrete + * @return array{reflector: \ReflectionClass, parameters: array} + */ + private function inspectBuildMetadata(string $concrete): array + { + $reflector = new \ReflectionClass($concrete); + + $constructor = $reflector->getConstructor(); + $parameters = []; + + foreach ($constructor?->getParameters() ?? [] as $parameter) { + $type = $parameter->getType(); + + $parameters[] = [ + 'name' => $parameter->getName(), + 'type' => $type, + 'allowsNull' => $type?->allowsNull() ?? false, + 'builtin' => $type?->isBuiltin() ?? false, + 'defaultAvailable' => $parameter->isDefaultValueAvailable(), + 'default' => $parameter->isDefaultValueAvailable() ? $parameter->getDefaultValue() : null, + 'variadic' => $parameter->isVariadic(), + ]; + } + + return ['reflector' => $reflector, 'parameters' => $parameters]; + } + /** * Freeze an object handed out by a closure binding or instance() * @@ -470,6 +506,62 @@ protected function resolveDependencies(array $parameters, array $primitives = [] return $dependencies; } + /** + * Resolve constructor dependencies from cached parameter metadata. + * + * @param array $parameters + * @param array $primitives + * @param string $className + * @return array + */ + protected function resolveCachedDependencies(array $parameters, array $primitives = [], string $className = ''): array + { + $dependencies = []; + + foreach ($parameters as $parameter) { + $name = $parameter['name']; + $type = $parameter['type']; + + if (!empty($primitives) && array_key_exists($name, $primitives)) { + $dependencies[] = $primitives[$name]; + continue; + } + + if ($type !== null && !$parameter['builtin']) { + $typeName = $type->getName(); + + if ($parameter['allowsNull'] && !$this->has($typeName)) { + $dependencies[] = null; + continue; + } + + $dependencies[] = $this->get($typeName); + continue; + } + + if ($parameter['defaultAvailable']) { + $dependencies[] = $parameter['default']; + continue; + } + + if ($parameter['variadic']) { + $dependencies[] = $primitives; + continue; + } + + if (!empty($primitives)) { + $dependencies[] = array_shift($primitives); + continue; + } + + throw new \RuntimeException( + "Unresolvable dependency resolving [\${$name}] in class " . $className + ); + } + + return $dependencies; + } + /** * Resolve a single dependency * @@ -626,6 +718,7 @@ public function flush(): void $this->instances = []; $this->aliases = []; $this->resolving = []; + $this->buildMetadata = []; $this->bootBindingKeys = []; $this->bootBindingsSnapshotted = false; } diff --git a/src/Phaseolies/Http/Request.php b/src/Phaseolies/Http/Request.php index b0724db6..2e2c91f7 100644 --- a/src/Phaseolies/Http/Request.php +++ b/src/Phaseolies/Http/Request.php @@ -592,7 +592,7 @@ public function merge(array $input): self */ public function getPath(): string { - return urldecode( + return $this->requestUri ??= urldecode( parse_url($this->server->get("REQUEST_URI", "/"), PHP_URL_PATH) ); } diff --git a/src/Phaseolies/Support/Router.php b/src/Phaseolies/Support/Router.php index 41e9e4fc..0f4036eb 100644 --- a/src/Phaseolies/Support/Router.php +++ b/src/Phaseolies/Support/Router.php @@ -34,6 +34,22 @@ class Router */ private ?ActionPlanStore $actionPlans = null; + /** + * Compiled route matchers keyed by HTTP method and route pattern. + * + * @var array}>> + */ + private array $compiledRouteMatchers = []; + + /** + * Static routes that can be resolved without scanning the route table. + * + * @var array> + */ + private array $staticRouteLookup = []; + + private bool $routeIndexesBuilt = false; + /** * The route dispatched for each request by getCallback() * @@ -221,12 +237,15 @@ public function cacheRoutes(): void $cacheData = [ 'routes' => $this->getCacheableRoutes(), + 'compiledRoutes' => $this->compileRouteMatchers(self::$routes), 'namedRoutes' => self::$namedRoutes, 'namedRouteMethods' => self::$namedRouteMethods, 'routeMiddlewares' => self::$routeMiddlewares, 'timestamp' => time() ]; + $this->compiledRouteMatchers = $cacheData['compiledRoutes']; + file_put_contents(static::$cachePath, 'cacheActionPlans($cacheData['routes']); @@ -333,6 +352,9 @@ public function loadCachedRoutes(): bool // A cache from before names kept their paths per method resolves by path alone. self::$namedRouteMethods = array_filter($cached['namedRouteMethods'] ?? [], 'is_array'); self::$routeMiddlewares = $cached['routeMiddlewares'] ?? []; + $this->compiledRouteMatchers = $cached['compiledRoutes'] ?? []; + $this->staticRouteLookup = []; + $this->routeIndexesBuilt = false; static::$cacheLoaded = true; @@ -768,6 +790,9 @@ protected function addRoute(string $method, string $path, $callback, ?string $do : $callback; self::$routes[$method][$fullPath] = $entry; + $this->routeIndexesBuilt = false; + $this->staticRouteLookup = []; + $this->compiledRouteMatchers[$method] = []; $this->currentRoutePath = $fullPath; $this->currentRequestMethod = $method; @@ -1266,6 +1291,17 @@ protected function matchRoute(string $method, $request): ?array $url = ($url !== '/') ? rtrim($url, '/') : $url; + $this->buildRouteIndexes(); + + if (array_key_exists($url, $this->staticRouteLookup[$method] ?? [])) { + $entry = $this->staticRouteLookup[$method][$url]; + ['callback' => $callback, 'domain' => $domain] = $this->unwrapRouteEntry($entry); + + if ($domain === null || $this->matchesDomain($domain, $request)) { + return ['method' => $method, 'route' => $url, 'callback' => $callback]; + } + } + $routes = self::$routes[$method] ?? []; foreach ($routes as $route => $entry) { @@ -1283,11 +1319,16 @@ protected function matchRoute(string $method, $request): ?array return $match; } + if (!str_contains((string) $route, '{') && !str_contains((string) $route, '*')) { + continue; + } + // Pattern match with named parameters - $routeRegex = $this->convertRouteToRegex($route); + $matcher = $this->compiledRouteMatchers[$method][$route] + ??= $this->compileRouteMatcher((string) $route); - if (preg_match($routeRegex, $url, $matches)) { - $params = $this->extractRouteParameters($route, $matches); + if (preg_match($matcher['regex'], $url, $matches)) { + $params = $this->extractCompiledRouteParameters($matcher['parameters'], $matches); if ($params !== false) { $request->setRouteParams(array_merge($request->getRouteParams(), $params)); @@ -1351,13 +1392,16 @@ protected function matchesDomain(string $domain, $request): bool * @param string $route * @return string */ - protected function convertRouteToRegex(string $route): string + protected function convertRouteToRegex(string $route, ?array &$parameterNames = null): string { + $parameterNames ??= []; + // Lift the parameters out first: a constraint can contain `/` and `*`, // which must not be touched by the escaping and wildcard steps below. $params = []; - $route = preg_replace_callback(self::PARAM_PATTERN, function (array $m) use (&$params) { + $route = preg_replace_callback(self::PARAM_PATTERN, function (array $m) use (&$params, &$parameterNames) { $params[] = [$m[1], $m[2] ?? null]; + $parameterNames[] = $m[1]; return "\x00" . (count($params) - 1) . "\x00"; }, $route); @@ -1386,11 +1430,23 @@ protected function convertRouteToRegex(string $route): string */ protected function extractRouteParameters(string $route, array $matches): array|false { - // Get all named parameters from the route pattern preg_match_all(self::PARAM_PATTERN, $route, $paramNames); + + return $this->extractCompiledRouteParameters($paramNames[1], $matches); + } + + /** + * Extract route parameters from a precompiled parameter-name list. + * + * @param array $parameterNames + * @param array $matches + * @return array|false + */ + protected function extractCompiledRouteParameters(array $parameterNames, array $matches): array|false + { $params = []; - foreach ($paramNames[1] as $name) { + foreach ($parameterNames as $name) { if (!isset($matches[$name])) { return false; } @@ -1400,6 +1456,76 @@ protected function extractRouteParameters(string $route, array $matches): array| return $params; } + /** + * Compile all dynamic routes for persistence in the route cache. + * + * @param array> $routes + * @return array}>> + */ + protected function compileRouteMatchers(array $routes): array + { + $compiled = []; + + foreach ($routes as $method => $methodRoutes) { + foreach (array_keys($methodRoutes) as $route) { + if ($route === '(.*)' || (!str_contains((string) $route, '{') && !str_contains((string) $route, '*'))) { + continue; + } + + $compiled[$method][$route] = $this->compileRouteMatcher((string) $route); + } + } + + return $compiled; + } + + /** + * Build the safe direct-lookup index for static routes. + * + * @return void + */ + protected function buildRouteIndexes(): void + { + if ($this->routeIndexesBuilt) { + return; + } + + $this->staticRouteLookup = []; + + foreach (self::$routes as $method => $routes) { + $dynamicRouteSeen = false; + + foreach ($routes as $route => $entry) { + $route = (string) $route; + + if ($route === '(.*)' || str_contains($route, '{') || str_contains($route, '*')) { + $dynamicRouteSeen = true; + continue; + } + + if (!$dynamicRouteSeen) { + $this->staticRouteLookup[$method][$route] = $entry; + } + } + } + + $this->routeIndexesBuilt = true; + } + + /** + * Compile one dynamic route and retain its parameter names. + * + * @param string $route + * @return array{regex: string, parameters: array} + */ + protected function compileRouteMatcher(string $route): array + { + $parameters = []; + $regex = $this->convertRouteToRegex($route, $parameters); + + return ['regex' => $regex, 'parameters' => $parameters]; + } + /** * Checks if the request is a modifying request (POST, PUT, PATCH, DELETE). * diff --git a/tests/Router/RouterTest.php b/tests/Router/RouterTest.php index b2292f0d..569ba3f6 100644 --- a/tests/Router/RouterTest.php +++ b/tests/Router/RouterTest.php @@ -909,6 +909,29 @@ public function testExtractRouteParametersExtractsParams(): void $this->assertEquals(['id' => '123', 'name' => 'john'], $result); } + public function testStaticRouteDoesNotTreatRegexCharactersAsWildcards(): void + { + $callback = fn() => 'file'; + $this->router->get('/file.txt', $callback); + + $result = $this->router->getCallback(new TestRequestStub('GET', '/fileXtxt', 'localhost')); + + $this->assertFalse($result); + } + + public function testDynamicRouteDeclaredBeforeStaticRouteKeepsPrecedence(): void + { + $dynamic = fn() => 'dynamic'; + $static = fn() => 'static'; + + $this->router->get('/users/{id}', $dynamic); + $this->router->get('/users/list', $static); + + $result = $this->router->getCallback(new TestRequestStub('GET', '/users/list', 'localhost')); + + $this->assertSame($dynamic, $result); + } + public function testConvertRouteToRegex(): void { $reflection = new \ReflectionClass(Router::class); From 3006733278b9b6dd04ddd2c73220ad408dd12db2 Mon Sep 17 00:00:00 2001 From: techmahedy Date: Thu, 12 Nov 2026 12:43:12 +0600 Subject: [PATCH 2/2] fix: resolve PHPStan reflection type errors --- src/Phaseolies/DI/Container.php | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/src/Phaseolies/DI/Container.php b/src/Phaseolies/DI/Container.php index e3fb4dc0..cc7e74b9 100644 --- a/src/Phaseolies/DI/Container.php +++ b/src/Phaseolies/DI/Container.php @@ -49,7 +49,7 @@ class Container implements ArrayAccess /** * Cached reflection metadata for concrete classes. * - * @var array}> + * @var array}> */ private array $buildMetadata = []; @@ -418,7 +418,7 @@ public function build(string $concrete, array $parameters = []): object * Inspect a concrete class once and reuse the metadata for future builds. * * @param class-string $concrete - * @return array{reflector: \ReflectionClass, parameters: array} + * @return array{reflector: \ReflectionClass, parameters: array} */ private function inspectBuildMetadata(string $concrete): array { @@ -433,8 +433,9 @@ private function inspectBuildMetadata(string $concrete): array $parameters[] = [ 'name' => $parameter->getName(), 'type' => $type, + 'typeName' => $type instanceof \ReflectionNamedType ? $type->getName() : null, 'allowsNull' => $type?->allowsNull() ?? false, - 'builtin' => $type?->isBuiltin() ?? false, + 'builtin' => $type instanceof \ReflectionNamedType && $type->isBuiltin(), 'defaultAvailable' => $parameter->isDefaultValueAvailable(), 'default' => $parameter->isDefaultValueAvailable() ? $parameter->getDefaultValue() : null, 'variadic' => $parameter->isVariadic(), @@ -509,7 +510,7 @@ protected function resolveDependencies(array $parameters, array $primitives = [] /** * Resolve constructor dependencies from cached parameter metadata. * - * @param array $parameters + * @param array $parameters * @param array $primitives * @param string $className * @return array @@ -527,8 +528,8 @@ protected function resolveCachedDependencies(array $parameters, array $primitive continue; } - if ($type !== null && !$parameter['builtin']) { - $typeName = $type->getName(); + if ($type !== null && !$parameter['builtin'] && $parameter['typeName'] !== null) { + $typeName = $parameter['typeName']; if ($parameter['allowsNull'] && !$this->has($typeName)) { $dependencies[] = null;