From c17cb83e477584c336f422bde53a5c81240b7492 Mon Sep 17 00:00:00 2001 From: Albert Chen Date: Sun, 16 Aug 2026 00:48:31 +0800 Subject: [PATCH 01/34] perf(foundation): compare maintenance snapshot age as a timestamp PreventRequestsDuringMaintenance calls active() on every request, and WorkerCachedMaintenanceMode answered "have five seconds elapsed" by building two CarbonImmutable instances and an interval -- more than the check it guards. Store the refresh time as a float and subtract. --- .../src/WorkerCachedMaintenanceMode.php | 27 ++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) diff --git a/src/foundation/src/WorkerCachedMaintenanceMode.php b/src/foundation/src/WorkerCachedMaintenanceMode.php index 2ac229201..d56df2886 100644 --- a/src/foundation/src/WorkerCachedMaintenanceMode.php +++ b/src/foundation/src/WorkerCachedMaintenanceMode.php @@ -21,8 +21,13 @@ class WorkerCachedMaintenanceMode implements MaintenanceModeContract /** * The time when the cached snapshot was last refreshed. + * + * Stored as a Unix timestamp float rather than a Carbon instance: this is + * read on every request through the global middleware stack, and Carbon's + * interval arithmetic costs microseconds per comparison where a float + * subtraction costs nanoseconds. */ - protected static ?CarbonImmutable $refreshedAt = null; + protected static ?float $refreshedAt = null; /** * Create a new worker-cached maintenance mode instance. @@ -101,7 +106,7 @@ protected function loadSnapshot(): array ]; // Set after successful reads so failed refreshes retry on the next request. - static::$refreshedAt = CarbonImmutable::now(); + static::$refreshedAt = static::now(); } return static::$snapshot; @@ -117,6 +122,22 @@ protected function shouldRefreshSnapshot(): bool } return $this->refreshInterval > 0 - && static::$refreshedAt->addSeconds($this->refreshInterval)->lte(CarbonImmutable::now()); + && static::now() - static::$refreshedAt >= $this->refreshInterval; + } + + /** + * Get the current time as a Unix timestamp in seconds. + * + * This runs on every request through the global middleware stack, so the + * common path uses microtime() rather than Carbon interval arithmetic — + * building two Carbon instances and an interval to compare them costs + * microseconds where a float subtraction costs nanoseconds. Carbon is only + * consulted when a test now is set, so time travel still drives refreshes. + */ + protected static function now(): float + { + return CarbonImmutable::hasTestNow() + ? (float) CarbonImmutable::now()->getTimestamp() + : microtime(true); } } From 879a3ee7251fd94b1405fdc61c65d6f27f07e3c2 Mon Sep 17 00:00:00 2001 From: Albert Chen Date: Sun, 16 Aug 2026 00:50:53 +0800 Subject: [PATCH 02/34] perf(http): match paths before full URLs in path predicates HandleCors and ExcludesPaths checked `fullUrlIs($pattern) || is($pattern)`, and the cheaper operand was second: fullUrlIs() rebuilds the absolute URL to match patterns almost always written as paths, at 2.67us against 0.65us. Both also built a Collection per call. --- .../Middleware/Concerns/ExcludesPaths.php | 5 +++- src/http/src/Middleware/HandleCors.php | 5 +++- src/http/src/Request.php | 28 ++++++++++++++++--- 3 files changed, 32 insertions(+), 6 deletions(-) diff --git a/src/foundation/src/Http/Middleware/Concerns/ExcludesPaths.php b/src/foundation/src/Http/Middleware/Concerns/ExcludesPaths.php index 2b5202e48..78e349bd2 100644 --- a/src/foundation/src/Http/Middleware/Concerns/ExcludesPaths.php +++ b/src/foundation/src/Http/Middleware/Concerns/ExcludesPaths.php @@ -18,7 +18,10 @@ protected function inExceptArray(Request $request): bool $except = trim($except, '/'); } - if ($request->fullUrlIs($except) || $request->is($except)) { + // is() is checked first: it matches against the decoded path, while + // fullUrlIs() has to rebuild the absolute URL. Both are pure, so the + // order only decides which one gets to short-circuit the other. + if ($request->is($except) || $request->fullUrlIs($except)) { return true; } } diff --git a/src/http/src/Middleware/HandleCors.php b/src/http/src/Middleware/HandleCors.php index 4ec26e2ef..39064088b 100644 --- a/src/http/src/Middleware/HandleCors.php +++ b/src/http/src/Middleware/HandleCors.php @@ -90,7 +90,10 @@ protected function hasMatchingPath(Request $request, array $paths): bool $path = trim($path, '/'); } - if ($request->fullUrlIs($path) || $request->is($path)) { + // is() is checked first: it matches against the decoded path, while + // fullUrlIs() has to rebuild the absolute URL. Both are pure, so the + // order only decides which one gets to short-circuit the other. + if ($request->is($path) || $request->fullUrlIs($path)) { return true; } } diff --git a/src/http/src/Request.php b/src/http/src/Request.php index 2c086b460..a5343a04e 100644 --- a/src/http/src/Request.php +++ b/src/http/src/Request.php @@ -394,8 +394,18 @@ public function segments(): array */ public function is(mixed ...$patterns): bool { - return (new Collection($patterns)) - ->contains(fn ($pattern) => Str::is($pattern, $this->decodedPath())); + // Hot path (global middleware runs this per request), so the subject is + // resolved once instead of per pattern and matched without wrapping the + // patterns in a Collection. + $path = $this->decodedPath(); + + foreach ($patterns as $pattern) { + if (Str::is($pattern, $path)) { + return true; + } + } + + return false; } /** @@ -411,8 +421,18 @@ public function routeIs(mixed ...$patterns): bool */ public function fullUrlIs(mixed ...$patterns): bool { - return (new Collection($patterns)) - ->contains(fn ($pattern) => Str::is($pattern, $this->fullUrl())); + // Hot path (global middleware runs this per request), so the URL is + // rebuilt once instead of per pattern and matched without wrapping the + // patterns in a Collection. + $url = $this->fullUrl(); + + foreach ($patterns as $pattern) { + if (Str::is($pattern, $url)) { + return true; + } + } + + return false; } /** From 0e24add42490830b5d5713f8dbefab90adaa53b9 Mon Sep 17 00:00:00 2001 From: Albert Chen Date: Sun, 16 Aug 2026 00:53:51 +0800 Subject: [PATCH 03/34] perf(foundation): memoize which middleware are terminable terminateMiddleware() parsed and resolved every middleware on each request to ask method_exists($instance, 'terminate'), discovering that eight of the nine default middleware have none. Bindings are registered at boot, so the answer cannot change between requests. Keyed by the middleware string with its parameters, so 'throttle:60,1' and 'throttle:10,1' stay separate. --- src/foundation/src/Http/Kernel.php | 26 +++++++++++++- tests/Foundation/Http/KernelTest.php | 53 ++++++++++++++++++++++++++++ 2 files changed, 78 insertions(+), 1 deletion(-) diff --git a/src/foundation/src/Http/Kernel.php b/src/foundation/src/Http/Kernel.php index d3aba0dd3..565e6f450 100644 --- a/src/foundation/src/Http/Kernel.php +++ b/src/foundation/src/Http/Kernel.php @@ -81,6 +81,18 @@ class Kernel implements KernelContract */ protected array $requestLifecycleDurationHandlers = []; + /** + * Whether each middleware name resolves to a terminable instance. + * + * Keyed by the middleware string as it appears in the stack, parameters + * included. Populated on first termination and reused for the worker + * lifetime, so repeat requests skip parsing and resolving middleware that + * turned out to have no terminate() method. + * + * @var array + */ + protected array $terminableMiddleware = []; + /** * Context key for the current request's start time. * @@ -291,12 +303,24 @@ protected function terminateMiddleware(Request $request, Response $response): vo continue; } + // Most middleware are not terminable, and resolving each one only to + // find it has no terminate() is the dominant cost of this method. + // Whether a name resolves to something terminable is fixed once + // bindings are registered — those are boot-only — so the answer is + // memoized and non-terminable middleware skip both the name parse + // and the resolution on later requests. + if (($this->terminableMiddleware[$middleware] ?? null) === false) { + continue; + } + try { [$name] = $this->parseMiddleware($middleware); $instance = $this->app->make($name); + $terminable = method_exists($instance, 'terminate'); + $this->terminableMiddleware[$middleware] = $terminable; - if (method_exists($instance, 'terminate')) { + if ($terminable) { $instance->terminate($request, $response); } } catch (Throwable $throwable) { diff --git a/tests/Foundation/Http/KernelTest.php b/tests/Foundation/Http/KernelTest.php index 6f2ebfb26..032022bc4 100644 --- a/tests/Foundation/Http/KernelTest.php +++ b/tests/Foundation/Http/KernelTest.php @@ -262,6 +262,59 @@ public function terminate($request, $response): void ], $called); } + public function testItTerminatesTerminableMiddlewareOnEveryRequest(): void + { + $app = new Application; + $events = new Dispatcher($app); + $app->instance('events', $events); + $kernel = new Kernel($app, new Router($events, $app)); + + $terminable = new class { + public int $terminated = 0; + + public function handle($request, $next) + { + return $next($request); + } + + public function terminate($request, $response): void + { + ++$this->terminated; + } + }; + + $nonTerminable = new class { + public int $resolved = 0; + + public function handle($request, $next) + { + return $next($request); + } + }; + + $app->instance('terminable-middleware', $terminable); + $app->bind('non-terminable-middleware', function () use ($nonTerminable) { + ++$nonTerminable->resolved; + + return $nonTerminable; + }); + + $kernel->setGlobalMiddleware([ + 'terminable-middleware', + 'non-terminable-middleware', + ]); + + $kernel->terminate(new Request, new Response); + $kernel->terminate(new Request, new Response); + $kernel->terminate(new Request, new Response); + + $this->assertSame(3, $terminable->terminated); + + // The terminability answer is memoized for the worker lifetime, so a + // middleware without terminate() is resolved once and skipped after. + $this->assertSame(1, $nonTerminable->resolved); + } + public function testHandleReportsAndRendersRouterFailures(): void { $app = new Application; From 25b646a79de78784b9b33b4bb288884dc362d03d Mon Sep 17 00:00:00 2001 From: Albert Chen Date: Sun, 16 Aug 2026 00:57:54 +0800 Subject: [PATCH 04/34] perf(container): skip resolving scoped bindings nothing asked for InvokeDeferredCallbacks::terminate() resolved DeferredCallbackCollection on every request to iterate it, and it is empty unless that request called defer(). Scoped instances are coroutine-local and every request is a new coroutine, so the cache misses every time: 13.47us to build a collection and walk it empty. Scoped is an isolation mechanism here, not a cache -- which is what made that cost look like an optimization. Add Container::resolvedScoped(), reporting whether a scoped binding was already resolved in the current coroutine, and return early when not. --- src/container/src/Container.php | 18 +++++ .../Middleware/InvokeDeferredCallbacks.php | 12 +++- src/support/src/Facades/App.php | 1 + tests/Container/ContainerTest.php | 64 ++++++++++++++++++ .../InvokeDeferredCallbacksTest.php | 66 +++++++++++++++++++ 5 files changed, 159 insertions(+), 2 deletions(-) create mode 100644 tests/Foundation/Http/Middleware/InvokeDeferredCallbacksTest.php diff --git a/src/container/src/Container.php b/src/container/src/Container.php index 20a164633..0a4f5d5f8 100755 --- a/src/container/src/Container.php +++ b/src/container/src/Container.php @@ -381,6 +381,24 @@ public function isScoped(string $abstract): bool return false; } + /** + * Determine if a scoped binding has already been resolved in this coroutine. + * + * Scoped instances live in coroutine-local context, so this is false at the + * start of every request even for a binding resolved many times before. + * Callers that would only inspect an instance can use this to avoid paying + * for a full resolution — and the construction it implies — when nothing in + * the request asked for one. + * + * Only the scoped resolution path writes this context key, so its presence + * already implies the binding is scoped; singletons, auto-singletons and + * transient bindings never produce one. + */ + public function resolvedScoped(string $abstract): bool + { + return CoroutineContext::has(self::SCOPED_CONTEXT_PREFIX . $this->getAlias($abstract)); + } + /** * Determine if a ReflectionClass has scoping attributes applied. * diff --git a/src/foundation/src/Http/Middleware/InvokeDeferredCallbacks.php b/src/foundation/src/Http/Middleware/InvokeDeferredCallbacks.php index 851c225a9..cb8615885 100644 --- a/src/foundation/src/Http/Middleware/InvokeDeferredCallbacks.php +++ b/src/foundation/src/Http/Middleware/InvokeDeferredCallbacks.php @@ -26,8 +26,16 @@ public function handle(Request $request, Closure $next): Response */ public function terminate(Request $request, Response $response): void { - Container::getInstance() - ->make(DeferredCallbackCollection::class) + $container = Container::getInstance(); + + // The collection is scoped, so it only exists here if something in this + // request actually called defer(). Resolving it otherwise would build a + // collection on every request just to iterate nothing. + if (! $container->resolvedScoped(DeferredCallbackCollection::class)) { + return; + } + + $container->make(DeferredCallbackCollection::class) ->invokeWhen(fn (DeferredCallback $callback) => $response->getStatusCode() < 400 || $callback->always); } } diff --git a/src/support/src/Facades/App.php b/src/support/src/Facades/App.php index eb82fcc40..ac7143f67 100644 --- a/src/support/src/Facades/App.php +++ b/src/support/src/Facades/App.php @@ -100,6 +100,7 @@ * @method static \Hypervel\Support\ServiceProvider register(\Hypervel\Support\ServiceProvider|string $provider, bool $force = false) * @method static void registerConfiguredProviders() * @method static void registered(callable $callback) + * @method static bool resolvedScoped(string $abstract) * @method static void resolveEnvironmentUsing(null|callable $callback) * @method static mixed resolveFromAttribute(\ReflectionAttribute $attribute, \ReflectionParameter $parameter) * @method static \Hypervel\Support\ServiceProvider resolveProvider(string $provider) diff --git a/tests/Container/ContainerTest.php b/tests/Container/ContainerTest.php index b71e34a2d..470b82964 100755 --- a/tests/Container/ContainerTest.php +++ b/tests/Container/ContainerTest.php @@ -24,6 +24,8 @@ use stdClass; use TypeError; +use function Hypervel\Coroutine\parallel; + class ContainerTest extends TestCase { public function testContainerSingleton() @@ -678,6 +680,68 @@ public function testForgettingTemporaryInstanceRestoresScopedLifecycle(): void $this->assertSame($restored, $container->make(ContainerConcreteStub::class)); } + public function testResolvedScopedReportsWhetherAScopedBindingWasResolved(): void + { + $container = new Container; + $container->scoped(ContainerConcreteStub::class); + + $this->assertFalse($container->resolvedScoped(ContainerConcreteStub::class)); + + $container->make(ContainerConcreteStub::class); + + $this->assertTrue($container->resolvedScoped(ContainerConcreteStub::class)); + } + + public function testResolvedScopedIsFalseForBindingsThatAreNotScoped(): void + { + $container = new Container; + $container->singleton('singleton', fn (): stdClass => new stdClass); + $container->bind('transient', fn (): stdClass => new stdClass); + + $container->make('singleton'); + $container->make('transient'); + + $this->assertFalse($container->resolvedScoped('singleton')); + $this->assertFalse($container->resolvedScoped('transient')); + $this->assertFalse($container->resolvedScoped('unbound')); + } + + public function testResolvedScopedResolvesAliases(): void + { + $container = new Container; + $container->scoped(ContainerConcreteStub::class); + $container->alias(ContainerConcreteStub::class, 'stub'); + + $this->assertFalse($container->resolvedScoped('stub')); + + $container->make(ContainerConcreteStub::class); + + $this->assertTrue($container->resolvedScoped('stub')); + } + + public function testResolvedScopedIsIsolatedBetweenCoroutines(): void + { + $container = new Container; + $container->scoped(ContainerConcreteStub::class); + + [$resolver, $observer] = parallel([ + function () use ($container) { + $container->make(ContainerConcreteStub::class); + usleep(5000); + + return $container->resolvedScoped(ContainerConcreteStub::class); + }, + function () use ($container) { + usleep(1000); + + return $container->resolvedScoped(ContainerConcreteStub::class); + }, + ]); + + $this->assertTrue($resolver); + $this->assertFalse($observer, 'A scoped instance resolved in one coroutine leaked into another.'); + } + public function testExplicitTransientBindingOverridesScopedAttribute(): void { $container = new Container; diff --git a/tests/Foundation/Http/Middleware/InvokeDeferredCallbacksTest.php b/tests/Foundation/Http/Middleware/InvokeDeferredCallbacksTest.php new file mode 100644 index 000000000..5de7dc5ba --- /dev/null +++ b/tests/Foundation/Http/Middleware/InvokeDeferredCallbacksTest.php @@ -0,0 +1,66 @@ +scoped(DeferredCallbackCollection::class); + + $ran = false; + defer(function () use (&$ran) { + $ran = true; + }); + + (new InvokeDeferredCallbacks)->terminate(new Request, new Response('', 200)); + + $this->assertTrue($ran, 'defer() callback did not run through the middleware.'); + } + + public function testItSkipsDeferredCallbacksOnFailedResponses(): void + { + $container = Container::setInstance(new Container); + $container->scoped(DeferredCallbackCollection::class); + + $ran = false; + $always = false; + + defer(function () use (&$ran) { + $ran = true; + }); + defer(function () use (&$always) { + $always = true; + }, always: true); + + (new InvokeDeferredCallbacks)->terminate(new Request, new Response('', 500)); + + $this->assertFalse($ran, 'A deferred callback ran despite the response failing.'); + $this->assertTrue($always, 'An always-deferred callback did not run on a failed response.'); + } + + public function testItDoesNotConstructTheCollectionWhenNothingDeferred(): void + { + $container = Container::setInstance(new Container); + $container->scoped(DeferredCallbackCollection::class); + + (new InvokeDeferredCallbacks)->terminate(new Request, new Response('', 200)); + + $this->assertFalse( + $container->resolvedScoped(DeferredCallbackCollection::class), + 'The collection was constructed even though nothing deferred.' + ); + } +} From 95261b587ff18219a477919f021bbf9c8f58cc1c Mon Sep 17 00:00:00 2001 From: Albert Chen Date: Sun, 16 Aug 2026 01:14:02 +0800 Subject: [PATCH 05/34] perf(pipeline): skip parsing pipes that carry no parameters Pipeline::carry() ran parsePipeString() on every pipe on every request to discover the pipe has no parameters. Only middleware written as 'throttle:60,1' carry any, and a str_contains() guard answers that five times cheaper. Pipes are still resolved when each closure runs, not when the onion is composed: resolving earlier would break laziness, since a middleware returning without calling next() means later pipes are never built. --- src/pipeline/src/Pipeline.php | 12 ++++-- tests/Pipeline/PipelineTest.php | 76 +++++++++++++++++++++++++++++++++ 2 files changed, 85 insertions(+), 3 deletions(-) diff --git a/src/pipeline/src/Pipeline.php b/src/pipeline/src/Pipeline.php index a47c9a8ad..766089eca 100644 --- a/src/pipeline/src/Pipeline.php +++ b/src/pipeline/src/Pipeline.php @@ -167,14 +167,20 @@ protected function carry(): Closure return $pipe($passable, $stack); } if (! is_object($pipe)) { - [$name, $parameters] = $this->parsePipeString($pipe); + // Only pipes written as 'name:arg,arg' carry parameters, so the + // parse is skipped for the common parameterless case. + if (str_contains($pipe, ':')) { + [$name, $parameters] = $this->parsePipeString($pipe); + $parameters = array_merge([$passable, $stack], $parameters); + } else { + $name = $pipe; + $parameters = [$passable, $stack]; + } // If the pipe is a string we will parse the string and resolve the class out // of the dependency injection container. We can then build a callable and // execute the pipe function giving in the parameters that are required. $pipe = $this->getContainer()->make($name); - - $parameters = array_merge([$passable, $stack], $parameters); } else { // If the pipe is already an object we'll just make a callable and pass it to // the pipe as-is. There is no need to do any extra parsing and formatting diff --git a/tests/Pipeline/PipelineTest.php b/tests/Pipeline/PipelineTest.php index 58b3a9860..b4e67992f 100644 --- a/tests/Pipeline/PipelineTest.php +++ b/tests/Pipeline/PipelineTest.php @@ -248,6 +248,48 @@ public function testPipelineViaChangesTheMethodBeingCalledOnThePipes(): void $this->assertSame('data', $result); } + public function testPipelineViaDispatchesPerMethodForTheSamePipeClass(): void + { + $container = new Container; + + // The pipe defines handle() but not missingMethod(), so piping it + // through the latter has to fall back to __invoke rather than reuse + // whatever the previous pipeline resolved for the same class. + $handled = (new Pipeline($container))->send('data') + ->through(PipelineTestPartialMethodPipe::class) + ->then(fn ($piped) => $piped); + + $invoked = (new Pipeline($container))->send('data') + ->through(PipelineTestPartialMethodPipe::class) + ->via('missingMethod') + ->then(fn ($piped) => $piped); + + $handledAgain = (new Pipeline($container))->send('data') + ->through(PipelineTestPartialMethodPipe::class) + ->then(fn ($piped) => $piped); + + $this->assertSame('data:handled', $handled); + $this->assertSame('data:invoked', $invoked, 'A missing pipe method did not fall back to __invoke.'); + $this->assertSame('data:handled', $handledAgain); + } + + public function testPipelineResolvesPipesOnlyWhenReached(): void + { + $container = new Container; + $container->bind(PipelineTestUnreachablePipe::class); + + $result = (new Pipeline($container))->send('data') + ->through([PipelineTestShortCircuitPipe::class, PipelineTestUnreachablePipe::class]) + ->then(fn ($piped) => $piped); + + $this->assertSame('short-circuited', $result); + $this->assertArrayNotHasKey( + '__test.pipe.unreachable', + $_SERVER, + 'A pipe was constructed even though an earlier pipe never called next().' + ); + } + public function testPipelineThrowsExceptionOnResolveWithoutContainer(): void { $this->expectException(RuntimeException::class); @@ -564,3 +606,37 @@ public function handle($piped, $next, $parameter1 = null, $parameter2 = null) return $next($piped); } } + +class PipelineTestShortCircuitPipe +{ + public function handle($piped, $next) + { + return 'short-circuited'; + } +} + +class PipelineTestPartialMethodPipe +{ + public function handle($piped, $next) + { + return $next($piped . ':handled'); + } + + public function __invoke($piped, $next) + { + return $next($piped . ':invoked'); + } +} + +class PipelineTestUnreachablePipe +{ + public function __construct() + { + $_SERVER['__test.pipe.unreachable'] = true; + } + + public function handle($piped, $next) + { + return $next($piped); + } +} From 487ed0c86931cb01d4f9b345ec515397cdc3fd91 Mon Sep 17 00:00:00 2001 From: Albert Chen Date: Sun, 16 Aug 2026 22:17:50 +0800 Subject: [PATCH 06/34] perf(http): clone a header bag prototype when building JSON responses Roughly four fifths of the 5.4us cost of building a JsonResponse is Symfony's ResponseHeaderBag constructor, which sets Cache-Control to an empty string only to parse it back out through a regex. None of it depends on the request, so the bag is built once per worker and cloned: 4.11us becomes 0.07us. Headers live in a plain array, so clones share nothing. The clone goes to SymfonyResponse::__construct, since JsonResponse's signature accepts only an array of headers, so the two lines the parent would have run are inlined. Date is refreshed explicitly -- the bag stamps it at construction, and clones would report their worker's boot time. --- src/http/src/JsonResponse.php | 34 +++++++++++++++++++++++++++++++++- 1 file changed, 33 insertions(+), 1 deletion(-) diff --git a/src/http/src/JsonResponse.php b/src/http/src/JsonResponse.php index 6b8ee3ddc..fda8ee7fb 100755 --- a/src/http/src/JsonResponse.php +++ b/src/http/src/JsonResponse.php @@ -4,6 +4,7 @@ namespace Hypervel\Http; +use ArrayObject; use Hypervel\Contracts\Support\Arrayable; use Hypervel\Contracts\Support\Jsonable; use Hypervel\Support\Json; @@ -12,6 +13,8 @@ use JsonSerializable; use Override; use Symfony\Component\HttpFoundation\JsonResponse as BaseJsonResponse; +use Symfony\Component\HttpFoundation\Response as SymfonyResponse; +use Symfony\Component\HttpFoundation\ResponseHeaderBag; class JsonResponse extends BaseJsonResponse { @@ -19,6 +22,11 @@ class JsonResponse extends BaseJsonResponse Macroable::__call as macroCall; } + /** + * The pristine header bag cloned for responses that add no headers of their own. + */ + protected static ?ResponseHeaderBag $headerPrototype = null; + /** * Create a new JSON response instance. */ @@ -26,7 +34,29 @@ public function __construct(mixed $data = null, int $status = 200, array $header { $this->encodingOptions = $options; - parent::__construct($data, $status, $headers, $json); + // Symfony builds a ResponseHeaderBag per response, and its constructor + // sets Cache-Control to an empty string only to parse that value back + // out through a regex — roughly four fifths of the cost of building a + // JSON response, for a result identical every time. Cloning a prototype + // skips it. SymfonyResponse::__construct is called rather than + // parent::__construct because JsonResponse's signature only accepts an + // array of headers, while Response's also accepts a prepared bag. + $bag = clone (static::$headerPrototype ??= new ResponseHeaderBag); + + // The bag stamps Date when it is constructed, so a clone carries the + // prototype's timestamp and has to be given the current one. Given + // headers are added after, so a caller supplying Date still wins. + $bag->set('Date', gmdate('D, d M Y H:i:s') . ' GMT'); + + if ($headers !== []) { + $bag->add($headers); + } + + SymfonyResponse::__construct('', $status, $bag); + + $data ??= new ArrayObject; + + $json ? $this->setJson($data) : $this->setData($data); } /** @@ -123,5 +153,7 @@ public function hasEncodingOption(int $option): bool public static function flushState(): void { static::flushMacros(); + + static::$headerPrototype = null; } } From 825a70197c86d54c6a65ecd9a9351b81a300f8ee Mon Sep 17 00:00:00 2001 From: Albert Chen Date: Mon, 17 Aug 2026 10:46:56 +0800 Subject: [PATCH 07/34] fix: fix bugs from code review --- .../Middleware/Concerns/ExcludesPaths.php | 20 ++++++++++++++++--- .../src/WorkerCachedMaintenanceMode.php | 5 ++++- src/http/src/Request.php | 14 +++++++++++++ tests/Foundation/Http/KernelTest.php | 12 +---------- .../WorkerCachedMaintenanceModeTest.php | 18 +++++++++++++++++ tests/Pipeline/PipelineTest.php | 9 +++++---- 6 files changed, 59 insertions(+), 19 deletions(-) diff --git a/src/foundation/src/Http/Middleware/Concerns/ExcludesPaths.php b/src/foundation/src/Http/Middleware/Concerns/ExcludesPaths.php index 78e349bd2..cce709b3c 100644 --- a/src/foundation/src/Http/Middleware/Concerns/ExcludesPaths.php +++ b/src/foundation/src/Http/Middleware/Concerns/ExcludesPaths.php @@ -13,14 +13,28 @@ trait ExcludesPaths */ protected function inExceptArray(Request $request): bool { - foreach ($this->getExcludedPaths() as $except) { + $excluded = $this->getExcludedPaths(); + + if ($excluded === []) { + return false; + } + + // Reading the host validates it against the trusted host patterns, and + // that must happen even when a path matches: is() only looks at the + // path, so short-circuiting on it would let an untrusted Host header + // through on excluded paths. Resolving it once up front keeps the + // validation and lets the cheap path match run first below. + $request->getHost(); + + foreach ($excluded as $except) { if ($except !== '/') { $except = trim($except, '/'); } // is() is checked first: it matches against the decoded path, while - // fullUrlIs() has to rebuild the absolute URL. Both are pure, so the - // order only decides which one gets to short-circuit the other. + // fullUrlIs() has to rebuild the absolute URL. Both are free of + // further side effects, so the order only decides which one gets to + // short-circuit the other. if ($request->is($except) || $request->fullUrlIs($except)) { return true; } diff --git a/src/foundation/src/WorkerCachedMaintenanceMode.php b/src/foundation/src/WorkerCachedMaintenanceMode.php index d56df2886..bc22ad205 100644 --- a/src/foundation/src/WorkerCachedMaintenanceMode.php +++ b/src/foundation/src/WorkerCachedMaintenanceMode.php @@ -136,8 +136,11 @@ protected function shouldRefreshSnapshot(): bool */ protected static function now(): float { + // Sub-second precision on both sides: getTimestamp() truncates to whole + // seconds, which over-reports the elapsed time between two fractional + // instants and would refresh the snapshot before the interval is up. return CarbonImmutable::hasTestNow() - ? (float) CarbonImmutable::now()->getTimestamp() + ? CarbonImmutable::now()->getPreciseTimestamp(6) / 1_000_000 : microtime(true); } } diff --git a/src/http/src/Request.php b/src/http/src/Request.php index a5343a04e..4f266a39f 100644 --- a/src/http/src/Request.php +++ b/src/http/src/Request.php @@ -394,6 +394,14 @@ public function segments(): array */ public function is(mixed ...$patterns): bool { + // Resolved before the loop, and only once there is something to match: + // the previous Collection::contains() never ran its callback for an + // empty pattern list, so resolving the subject unconditionally would + // start doing work — and, for fullUrlIs(), throwing — where it did not. + if ($patterns === []) { + return false; + } + // Hot path (global middleware runs this per request), so the subject is // resolved once instead of per pattern and matched without wrapping the // patterns in a Collection. @@ -421,6 +429,12 @@ public function routeIs(mixed ...$patterns): bool */ public function fullUrlIs(mixed ...$patterns): bool { + // See is(): rebuilding the URL for an empty pattern list would resolve + // the host, which validates it and can throw where 0.4 returned false. + if ($patterns === []) { + return false; + } + // Hot path (global middleware runs this per request), so the URL is // rebuilt once instead of per pattern and matched without wrapping the // patterns in a Collection. diff --git a/tests/Foundation/Http/KernelTest.php b/tests/Foundation/Http/KernelTest.php index 032022bc4..e008cfc57 100644 --- a/tests/Foundation/Http/KernelTest.php +++ b/tests/Foundation/Http/KernelTest.php @@ -272,12 +272,7 @@ public function testItTerminatesTerminableMiddlewareOnEveryRequest(): void $terminable = new class { public int $terminated = 0; - public function handle($request, $next) - { - return $next($request); - } - - public function terminate($request, $response): void + public function terminate(Request $request, Response $response): void { ++$this->terminated; } @@ -285,11 +280,6 @@ public function terminate($request, $response): void $nonTerminable = new class { public int $resolved = 0; - - public function handle($request, $next) - { - return $next($request); - } }; $app->instance('terminable-middleware', $terminable); diff --git a/tests/Foundation/WorkerCachedMaintenanceModeTest.php b/tests/Foundation/WorkerCachedMaintenanceModeTest.php index b1810cb52..1ab1685e9 100644 --- a/tests/Foundation/WorkerCachedMaintenanceModeTest.php +++ b/tests/Foundation/WorkerCachedMaintenanceModeTest.php @@ -103,6 +103,24 @@ public function testSnapshotRefreshesAtExactIntervalBoundary(): void $this->assertTrue($cached->active()); } + public function testSnapshotDoesNotRefreshBeforeIntervalAcrossFractionalSeconds(): void + { + CarbonImmutable::setTestNow($now = CarbonImmutable::parse('2026-01-01 00:00:00.900000')); + + $driver = m::mock(MaintenanceModeContract::class); + $driver->shouldReceive('active')->once()->andReturn(false); + + $cached = new WorkerCachedMaintenanceMode($driver, refreshInterval: 5); + + $this->assertFalse($cached->active()); + + // 4.2 seconds have passed, not 5: the whole-second parts differ by 5, + // so a truncated timestamp would report the interval as elapsed. + CarbonImmutable::setTestNow($now->addSeconds(4.2)); + + $this->assertFalse($cached->active()); + } + public function testActivePayloadRefreshesAfterInterval(): void { CarbonImmutable::setTestNow($now = CarbonImmutable::parse('2026-01-01 00:00:00')); diff --git a/tests/Pipeline/PipelineTest.php b/tests/Pipeline/PipelineTest.php index b4e67992f..ee628cb15 100644 --- a/tests/Pipeline/PipelineTest.php +++ b/tests/Pipeline/PipelineTest.php @@ -4,6 +4,7 @@ namespace Hypervel\Tests\Pipeline; +use Closure; use Exception; use Hypervel\Container\Container; use Hypervel\Database\Connection; @@ -609,7 +610,7 @@ public function handle($piped, $next, $parameter1 = null, $parameter2 = null) class PipelineTestShortCircuitPipe { - public function handle($piped, $next) + public function handle(mixed $piped, Closure $next): string { return 'short-circuited'; } @@ -617,12 +618,12 @@ public function handle($piped, $next) class PipelineTestPartialMethodPipe { - public function handle($piped, $next) + public function handle(mixed $piped, Closure $next): mixed { return $next($piped . ':handled'); } - public function __invoke($piped, $next) + public function __invoke(mixed $piped, Closure $next): mixed { return $next($piped . ':invoked'); } @@ -635,7 +636,7 @@ public function __construct() $_SERVER['__test.pipe.unreachable'] = true; } - public function handle($piped, $next) + public function handle(mixed $piped, Closure $next): mixed { return $next($piped); } From 11bf81f24f081fbbbf40788173f4439dd48648d6 Mon Sep 17 00:00:00 2001 From: Albert Chen Date: Mon, 17 Aug 2026 14:48:20 +0800 Subject: [PATCH 08/34] revert(http): restore the original order of the path predicates ExcludesPaths and HandleCors are back to 0.4's `fullUrlIs($p) || is($p)`, and the helper deciding when the second could be skipped is gone. Putting the cheap match first was not free: fullUrlIs() rebuilds the URL through getUri(), whose scheme, port and host getters each validate a trusted forwarded header. Short-circuiting on is() skipped that, so the swap needed explicit getHost(), isSecure() and getPort() calls to restore it, plus per-pattern analysis. Worth roughly 3% on API routes, nothing on web routes. ExcludesPathsTest stays: reordering the operands again fails two cases. --- .../Middleware/Concerns/ExcludesPaths.php | 21 +--- src/http/src/Middleware/HandleCors.php | 5 +- .../Middleware/Concerns/ExcludesPathsTest.php | 106 ++++++++++++++++++ tests/Http/Middleware/HandleCorsTest.php | 22 ++++ 4 files changed, 131 insertions(+), 23 deletions(-) create mode 100644 tests/Foundation/Http/Middleware/Concerns/ExcludesPathsTest.php diff --git a/src/foundation/src/Http/Middleware/Concerns/ExcludesPaths.php b/src/foundation/src/Http/Middleware/Concerns/ExcludesPaths.php index cce709b3c..2b5202e48 100644 --- a/src/foundation/src/Http/Middleware/Concerns/ExcludesPaths.php +++ b/src/foundation/src/Http/Middleware/Concerns/ExcludesPaths.php @@ -13,29 +13,12 @@ trait ExcludesPaths */ protected function inExceptArray(Request $request): bool { - $excluded = $this->getExcludedPaths(); - - if ($excluded === []) { - return false; - } - - // Reading the host validates it against the trusted host patterns, and - // that must happen even when a path matches: is() only looks at the - // path, so short-circuiting on it would let an untrusted Host header - // through on excluded paths. Resolving it once up front keeps the - // validation and lets the cheap path match run first below. - $request->getHost(); - - foreach ($excluded as $except) { + foreach ($this->getExcludedPaths() as $except) { if ($except !== '/') { $except = trim($except, '/'); } - // is() is checked first: it matches against the decoded path, while - // fullUrlIs() has to rebuild the absolute URL. Both are free of - // further side effects, so the order only decides which one gets to - // short-circuit the other. - if ($request->is($except) || $request->fullUrlIs($except)) { + if ($request->fullUrlIs($except) || $request->is($except)) { return true; } } diff --git a/src/http/src/Middleware/HandleCors.php b/src/http/src/Middleware/HandleCors.php index 39064088b..4ec26e2ef 100644 --- a/src/http/src/Middleware/HandleCors.php +++ b/src/http/src/Middleware/HandleCors.php @@ -90,10 +90,7 @@ protected function hasMatchingPath(Request $request, array $paths): bool $path = trim($path, '/'); } - // is() is checked first: it matches against the decoded path, while - // fullUrlIs() has to rebuild the absolute URL. Both are pure, so the - // order only decides which one gets to short-circuit the other. - if ($request->is($path) || $request->fullUrlIs($path)) { + if ($request->fullUrlIs($path) || $request->is($path)) { return true; } } diff --git a/tests/Foundation/Http/Middleware/Concerns/ExcludesPathsTest.php b/tests/Foundation/Http/Middleware/Concerns/ExcludesPathsTest.php new file mode 100644 index 000000000..230ba9337 --- /dev/null +++ b/tests/Foundation/Http/Middleware/Concerns/ExcludesPathsTest.php @@ -0,0 +1,106 @@ +assertTrue($excluder->check(Request::create('http://example.com/up'))); + $this->assertTrue($excluder->check(Request::create('http://example.com/api/users'))); + $this->assertFalse($excluder->check(Request::create('http://example.com/dashboard'))); + } + + public function testExcludesAbsoluteUrlPatterns(): void + { + // The full URL is only rebuilt for patterns that could match one, so a + // pattern written as an absolute URL still has to work. + $excluder = new ExcludesPathsTestExcluder(['http://example.com/admin/*']); + + $this->assertTrue($excluder->check(Request::create('http://example.com/admin/users'))); + $this->assertFalse($excluder->check(Request::create('http://other.com/admin/users'))); + } + + public function testEmptyExclusionListMatchesNothing(): void + { + $excluder = new ExcludesPathsTestExcluder([]); + + $this->assertFalse($excluder->check(Request::create('http://example.com/up'))); + } + + public function testUntrustedHostIsRejectedEvenWhenThePathIsExcluded(): void + { + $request = Request::create('http://evil.com/up'); + RequestContext::set($request); + Request::setTrustedHosts(['^allowed\.com$']); + + $excluder = new ExcludesPathsTestExcluder(['up']); + + $this->expectException(SuspiciousOperationException::class); + + $excluder->check($request); + } + + public function testConflictingForwardedHeadersAreRejectedEvenWhenThePathIsExcluded(): void + { + foreach (['host', 'proto', 'port'] as $axis) { + $request = $this->conflictingRequest($axis); + $excluder = new ExcludesPathsTestExcluder(['up']); + + try { + $excluder->check($request); + + $this->fail("A conflicting {$axis} header was not rejected."); + } catch (ConflictingHeadersException $exception) { + $this->assertInstanceOf(ConflictingHeadersException::class, $exception); + } + } + } + + /** + * Build a request whose Forwarded and X-Forwarded-* headers disagree on one axis. + */ + protected function conflictingRequest(string $axis): Request + { + $request = Request::create('http://allowed.com/up', 'GET', [], [], [], [ + 'REMOTE_ADDR' => '10.0.0.1', + 'HTTP_FORWARDED' => 'for=1.2.3.4;host=allowed.com;proto=https;port=443', + 'HTTP_X_FORWARDED_HOST' => $axis === 'host' ? 'evil.com' : 'allowed.com', + 'HTTP_X_FORWARDED_PROTO' => $axis === 'proto' ? 'http' : 'https', + 'HTTP_X_FORWARDED_PORT' => $axis === 'port' ? '8080' : '443', + ]); + + RequestContext::set($request); + + Request::setTrustedProxies(['10.0.0.1'], Request::HEADER_FORWARDED + | Request::HEADER_X_FORWARDED_FOR | Request::HEADER_X_FORWARDED_HOST + | Request::HEADER_X_FORWARDED_PROTO | Request::HEADER_X_FORWARDED_PORT); + + return $request; + } +} + +class ExcludesPathsTestExcluder +{ + use ExcludesPaths; + + public function __construct(protected array $except = []) + { + } + + public function check(Request $request): bool + { + return $this->inExceptArray($request); + } +} diff --git a/tests/Http/Middleware/HandleCorsTest.php b/tests/Http/Middleware/HandleCorsTest.php index 30dcc327c..bdb236fc1 100644 --- a/tests/Http/Middleware/HandleCorsTest.php +++ b/tests/Http/Middleware/HandleCorsTest.php @@ -325,6 +325,28 @@ protected function makeContainer(array $corsConfig): Container return $container; } + public function testPathsWrittenAsAbsoluteUrlsStillMatch(): void + { + // The full URL is only rebuilt for patterns that could match one, so a + // CORS path written as an absolute URL still has to be honored. + $response = $this->dispatchPreflight('admin/ping', [ + 'Origin' => 'http://localhost', + 'Access-Control-Request-Method' => 'POST', + ], ['paths' => ['http://localhost/admin/*']]); + + $this->assertSame('http://localhost', $response->headers->get('Access-Control-Allow-Origin')); + } + + public function testPathsWrittenAsAbsoluteUrlsDoNotMatchOtherPaths(): void + { + $response = $this->dispatchPreflight('public/ping', [ + 'Origin' => 'http://localhost', + 'Access-Control-Request-Method' => 'POST', + ], ['paths' => ['http://localhost/admin/*']]); + + $this->assertNull($response->headers->get('Access-Control-Allow-Origin')); + } + protected function makeRequest(string $method, string $path, array $headers = []): Request { $request = Request::create('http://localhost/' . ltrim($path, '/'), $method); From f3785e690755eb22f500a97ebbca292fedb678ae Mon Sep 17 00:00:00 2001 From: Albert Chen Date: Mon, 17 Aug 2026 14:48:38 +0800 Subject: [PATCH 09/34] perf(http): match request patterns without building a Collection is() and fullUrlIs() wrapped their patterns in a Collection and resolved the subject inside the callback, so decodedPath() or fullUrl() ran once per pattern. Loop instead, resolving it once. is() 0.86us -> 0.50us fullUrlIs() 7.97us -> 4.23us An empty list returns before the subject is touched, keeping what Collection::contains() did by never invoking its callback -- fullUrl() resolves the host, which validates it and can throw, so an unconditional rebuild would turn a no-op into an exception. --- src/http/src/Request.php | 20 ++++++++------------ 1 file changed, 8 insertions(+), 12 deletions(-) diff --git a/src/http/src/Request.php b/src/http/src/Request.php index 4f266a39f..3bc338899 100644 --- a/src/http/src/Request.php +++ b/src/http/src/Request.php @@ -394,17 +394,15 @@ public function segments(): array */ public function is(mixed ...$patterns): bool { - // Resolved before the loop, and only once there is something to match: - // the previous Collection::contains() never ran its callback for an - // empty pattern list, so resolving the subject unconditionally would - // start doing work — and, for fullUrlIs(), throwing — where it did not. + // Global middleware runs this per request, so the path is decoded once + // for the whole list rather than per pattern, and matched without + // wrapping the patterns in a Collection. Resolving it lazily also keeps + // Collection::contains()'s behavior of never touching the subject when + // there is nothing to match against. if ($patterns === []) { return false; } - // Hot path (global middleware runs this per request), so the subject is - // resolved once instead of per pattern and matched without wrapping the - // patterns in a Collection. $path = $this->decodedPath(); foreach ($patterns as $pattern) { @@ -429,15 +427,13 @@ public function routeIs(mixed ...$patterns): bool */ public function fullUrlIs(mixed ...$patterns): bool { - // See is(): rebuilding the URL for an empty pattern list would resolve - // the host, which validates it and can throw where 0.4 returned false. + // See is(). The empty check matters more here: rebuilding the URL + // resolves the host, which validates it and can throw, so an empty + // pattern list has to stay the no-op Collection::contains() made it. if ($patterns === []) { return false; } - // Hot path (global middleware runs this per request), so the URL is - // rebuilt once instead of per pattern and matched without wrapping the - // patterns in a Collection. $url = $this->fullUrl(); foreach ($patterns as $pattern) { From 89d690d873a8d02d6503fe0f330e0abeaaea3ee8 Mon Sep 17 00:00:00 2001 From: Albert Chen Date: Mon, 17 Aug 2026 14:54:48 +0800 Subject: [PATCH 10/34] chore: improve code in PipelineTest --- tests/Pipeline/PipelineTest.php | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/Pipeline/PipelineTest.php b/tests/Pipeline/PipelineTest.php index ee628cb15..cb1d0a411 100644 --- a/tests/Pipeline/PipelineTest.php +++ b/tests/Pipeline/PipelineTest.php @@ -258,16 +258,16 @@ public function testPipelineViaDispatchesPerMethodForTheSamePipeClass(): void // whatever the previous pipeline resolved for the same class. $handled = (new Pipeline($container))->send('data') ->through(PipelineTestPartialMethodPipe::class) - ->then(fn ($piped) => $piped); + ->then(fn (mixed $piped): mixed => $piped); $invoked = (new Pipeline($container))->send('data') ->through(PipelineTestPartialMethodPipe::class) ->via('missingMethod') - ->then(fn ($piped) => $piped); + ->then(fn (mixed $piped): mixed => $piped); $handledAgain = (new Pipeline($container))->send('data') ->through(PipelineTestPartialMethodPipe::class) - ->then(fn ($piped) => $piped); + ->then(fn (mixed $piped): mixed => $piped); $this->assertSame('data:handled', $handled); $this->assertSame('data:invoked', $invoked, 'A missing pipe method did not fall back to __invoke.'); @@ -281,7 +281,7 @@ public function testPipelineResolvesPipesOnlyWhenReached(): void $result = (new Pipeline($container))->send('data') ->through([PipelineTestShortCircuitPipe::class, PipelineTestUnreachablePipe::class]) - ->then(fn ($piped) => $piped); + ->then(fn (mixed $piped): mixed => $piped); $this->assertSame('short-circuited', $result); $this->assertArrayNotHasKey( From f69a2d9515940b3559b2eb3c26ca9ccb0df02836 Mon Sep 17 00:00:00 2001 From: Albert Chen Date: Mon, 17 Aug 2026 17:04:20 +0800 Subject: [PATCH 11/34] fix(http): preserve raw JSON constructor compatibility --- src/http/src/JsonResponse.php | 16 ++++++++- tests/Http/HttpJsonResponseTest.php | 50 +++++++++++++++++++++++++++++ 2 files changed, 65 insertions(+), 1 deletion(-) diff --git a/src/http/src/JsonResponse.php b/src/http/src/JsonResponse.php index fda8ee7fb..64740aa62 100755 --- a/src/http/src/JsonResponse.php +++ b/src/http/src/JsonResponse.php @@ -12,9 +12,11 @@ use InvalidArgumentException; use JsonSerializable; use Override; +use Stringable; use Symfony\Component\HttpFoundation\JsonResponse as BaseJsonResponse; use Symfony\Component\HttpFoundation\Response as SymfonyResponse; use Symfony\Component\HttpFoundation\ResponseHeaderBag; +use TypeError; class JsonResponse extends BaseJsonResponse { @@ -34,6 +36,18 @@ public function __construct(mixed $data = null, int $status = 200, array $header { $this->encodingOptions = $options; + if ($json + && ! is_string($data) + && ! is_numeric($data) + && ! $data instanceof Stringable + ) { + throw new TypeError(sprintf( + '"%s": If $json is set to true, argument $data must be a string or object implementing __toString(), "%s" given.', + BaseJsonResponse::class . '::__construct', + get_debug_type($data), + )); + } + // Symfony builds a ResponseHeaderBag per response, and its constructor // sets Cache-Control to an empty string only to parse that value back // out through a regex — roughly four fifths of the cost of building a @@ -56,7 +70,7 @@ public function __construct(mixed $data = null, int $status = 200, array $header $data ??= new ArrayObject; - $json ? $this->setJson($data) : $this->setData($data); + $json ? $this->setJson((string) $data) : $this->setData($data); } /** diff --git a/tests/Http/HttpJsonResponseTest.php b/tests/Http/HttpJsonResponseTest.php index 0f38a1282..0deccebb4 100644 --- a/tests/Http/HttpJsonResponseTest.php +++ b/tests/Http/HttpJsonResponseTest.php @@ -13,6 +13,9 @@ use JsonSerializable; use PHPUnit\Framework\Attributes\DataProvider; use stdClass; +use Stringable; +use Symfony\Component\HttpFoundation\JsonResponse as SymfonyJsonResponse; +use TypeError; class HttpJsonResponseTest extends TestCase { @@ -112,6 +115,45 @@ public function testFromJsonString(): void $this->assertSame('bar', $response->getData()->foo); } + #[DataProvider('rawJsonDataProvider')] + public function testRawJsonRetainsSymfonyConstructorCompatibility(mixed $data): void + { + $expected = new SymfonyJsonResponse($data, 201, ['X-Test' => 'value'], true); + $response = new JsonResponse($data, 201, ['X-Test' => 'value'], json: true); + + $this->assertSame($expected->getContent(), $response->getContent()); + $this->assertSame($expected->getStatusCode(), $response->getStatusCode()); + $this->assertSame('value', $response->headers->get('X-Test')); + } + + public static function rawJsonDataProvider(): array + { + return [ + 'string' => ['{"foo":"bar"}'], + 'integer' => [123], + 'float' => [12.5], + 'Stringable' => [new JsonResponseTestStringableObject], + ]; + } + + #[DataProvider('invalidRawJsonDataProvider')] + public function testRawJsonRejectsValuesSymfonyDoesNotAccept(mixed $data): void + { + $this->expectException(TypeError::class); + $this->expectExceptionMessage('If $json is set to true'); + + new JsonResponse($data, json: true); + } + + public static function invalidRawJsonDataProvider(): array + { + return [ + 'null' => [null], + 'array' => [[]], + 'ordinary object' => [new stdClass], + ]; + } + public function testDataRoundTripsAtTheMaximumSupportedNestingDepth(): void { $value = 'leaf'; @@ -167,3 +209,11 @@ public function toArray(): array return ['foo' => 'bar']; } } + +class JsonResponseTestStringableObject implements Stringable +{ + public function __toString(): string + { + return '{"foo":"bar"}'; + } +} From 15b71df695dfb154df02f237b734e7d35262bb06 Mon Sep 17 00:00:00 2001 From: Albert Chen Date: Mon, 17 Aug 2026 19:51:05 +0800 Subject: [PATCH 12/34] perf(foundation): materialize request start times lazily --- src/foundation/src/Http/Kernel.php | 23 +++++++++++++++---- tests/Foundation/Http/KernelTest.php | 34 ++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 5 deletions(-) diff --git a/src/foundation/src/Http/Kernel.php b/src/foundation/src/Http/Kernel.php index 565e6f450..e22ff7797 100644 --- a/src/foundation/src/Http/Kernel.php +++ b/src/foundation/src/Http/Kernel.php @@ -139,7 +139,10 @@ public function __construct(Application $app, Router $router) */ public function handle(Request $request): Response { - CoroutineContext::set(self::REQUEST_STARTED_AT_CONTEXT_KEY, CarbonImmutable::now()); + CoroutineContext::set( + self::REQUEST_STARTED_AT_CONTEXT_KEY, + CarbonImmutable::hasTestNow() ? CarbonImmutable::now() : microtime(true) + ); try { $request->enableHttpMethodParameterOverride(); @@ -244,9 +247,9 @@ public function terminate(Request $request, Response $response): void } try { - $requestStartedAt = CoroutineContext::get(self::REQUEST_STARTED_AT_CONTEXT_KEY); - - if ($requestStartedAt !== null && $this->requestLifecycleDurationHandlers !== []) { + if ($this->requestLifecycleDurationHandlers !== [] + && ($requestStartedAt = $this->requestStartedAt()) !== null + ) { $requestStartedAt = $requestStartedAt->setTimezone( $this->app->make('config')->string('app.timezone') ); @@ -357,7 +360,17 @@ public function whenRequestLifecycleIsLongerThan(DateTimeInterface|CarbonInterva */ public function requestStartedAt(): ?CarbonImmutable { - return CoroutineContext::get(self::REQUEST_STARTED_AT_CONTEXT_KEY); + $requestStartedAt = CoroutineContext::get(self::REQUEST_STARTED_AT_CONTEXT_KEY); + + if (is_float($requestStartedAt)) { + $requestStartedAt = CarbonImmutable::createFromTimestamp( + $requestStartedAt, + date_default_timezone_get() + ); + CoroutineContext::set(self::REQUEST_STARTED_AT_CONTEXT_KEY, $requestStartedAt); + } + + return $requestStartedAt; } /** diff --git a/tests/Foundation/Http/KernelTest.php b/tests/Foundation/Http/KernelTest.php index e008cfc57..6abe78964 100644 --- a/tests/Foundation/Http/KernelTest.php +++ b/tests/Foundation/Http/KernelTest.php @@ -5,6 +5,7 @@ namespace Hypervel\Tests\Foundation\Http; use Hypervel\Config\Repository; +use Hypervel\Context\CoroutineContext; use Hypervel\Contracts\Debug\ExceptionHandler; use Hypervel\Events\Dispatcher; use Hypervel\Foundation\Application; @@ -467,6 +468,39 @@ public function testDurationHandlerReceivesConvertedImmutableStartTimeFromContex $this->assertTrue($transportStartedAt->equalTo($request->startedAt())); } + public function testProductionRequestStartTimeIsMaterializedLazily(): void + { + $app = new Application; + $events = new Dispatcher($app); + $app->instance('events', $events); + $app->instance('config', new Repository(['app' => ['timezone' => 'UTC']])); + $app->bootstrapWith([]); + + $router = m::mock(Router::class); + $router->shouldReceive('dispatch')->once()->andReturn(new Response); + + $kernel = new class($app, $router) extends Kernel { + public function rawRequestStartedAt(): mixed + { + return CoroutineContext::get(self::REQUEST_STARTED_AT_CONTEXT_KEY); + } + }; + $request = Request::create('/'); + $response = $kernel->handle($request); + + $this->assertIsFloat($kernel->rawRequestStartedAt()); + + $startedAt = $kernel->requestStartedAt(); + + $this->assertInstanceOf(CarbonImmutable::class, $startedAt); + $this->assertSame($startedAt, $kernel->rawRequestStartedAt()); + $this->assertSame($startedAt, $kernel->requestStartedAt()); + + $kernel->terminate($request, $response); + + $this->assertNull($kernel->rawRequestStartedAt()); + } + public function testRequestStartedAtIsIsolatedBetweenConcurrentCoroutines(): void { $app = new Application; From 8e39543d2f96971f6ad5475d341b88ae6d37fa5d Mon Sep 17 00:00:00 2001 From: Albert Chen Date: Mon, 17 Aug 2026 19:51:44 +0800 Subject: [PATCH 13/34] perf(routing): reuse compiled route match parameters CompiledUrlMatcher already returns the decoded path and host parameters, but Route::bind() discarded them and ran the route regexes again. Keep the matcher result through dynamic-route, fallback, and port selection, then bind it only when the selected route is still the original compiled route. Defaults and URI parameter ordering still pass through RouteParameterBinder. Custom Route subclasses retain their bind() override, and any route selected from the dynamic collection follows the original binding path. --- src/routing/src/CompiledRouteCollection.php | 7 ++ src/routing/src/Route.php | 28 +++++ src/routing/src/RouteParameterBinder.php | 14 +++ .../Routing/CompiledRouteCollectionTest.php | 113 +++++++++++++++++- 4 files changed, 161 insertions(+), 1 deletion(-) diff --git a/src/routing/src/CompiledRouteCollection.php b/src/routing/src/CompiledRouteCollection.php index 0a9eedec4..2034489dd 100644 --- a/src/routing/src/CompiledRouteCollection.php +++ b/src/routing/src/CompiledRouteCollection.php @@ -189,6 +189,7 @@ public function match(Request $request): Route $path = rtrim($request->getPathInfo(), '/') ?: '/'; $route = null; + $result = null; try { if ($result = $matcher->match($path)) { @@ -201,6 +202,8 @@ public function match(Request $request): Route } } + $compiledRoute = $route; + $routePort = $route?->getPort(); if ($routePort !== null && $routePort !== (int) $request->getPort()) { @@ -222,6 +225,10 @@ public function match(Request $request): Route } } + if ($route !== null && $route === $compiledRoute && $result !== null) { + return $route->bindFromCompiledMatch($result, $request); + } + return $this->handleMatchedRoute($request, $route); } diff --git a/src/routing/src/Route.php b/src/routing/src/Route.php index b903f55fa..c431492cb 100755 --- a/src/routing/src/Route.php +++ b/src/routing/src/Route.php @@ -482,6 +482,34 @@ public function bind(Request $request): static $parameters = (new RouteParameterBinder($this))->parameters($request); + return $this->storeParameters($parameters); + } + + /** + * Bind parameters returned by the compiled route matcher. + * + * @internal + * + * @param array $parameters + */ + public function bindFromCompiledMatch(array $parameters, Request $request): static + { + if ($this::class !== self::class) { + return $this->bind($request); + } + + $this->compileRoute(); + + $parameters = (new RouteParameterBinder($this))->parametersFromCompiledMatch($parameters); + + return $this->storeParameters($parameters); + } + + /** + * Store the route's current and original parameters for this coroutine. + */ + private function storeParameters(array $parameters): static + { CoroutineContext::set($this->parametersContextKey(), $parameters); CoroutineContext::set($this->originalParametersContextKey(), $parameters); diff --git a/src/routing/src/RouteParameterBinder.php b/src/routing/src/RouteParameterBinder.php index 7e8bd2ab5..b23768aee 100644 --- a/src/routing/src/RouteParameterBinder.php +++ b/src/routing/src/RouteParameterBinder.php @@ -37,6 +37,20 @@ public function parameters(Request $request): array return $this->replaceDefaults($parameters); } + /** + * Get route parameters from a compiled matcher result. + * + * @param array $parameters + */ + public function parametersFromCompiledMatch(array $parameters): array + { + if ($this->route->parameterNames() === [] && $this->route->defaults === []) { + return []; + } + + return $this->replaceDefaults($this->matchToKeys($parameters)); + } + /** * Get the parameter matches for the path portion of the URI. */ diff --git a/tests/Integration/Routing/CompiledRouteCollectionTest.php b/tests/Integration/Routing/CompiledRouteCollectionTest.php index 120dbfb94..8983bd833 100644 --- a/tests/Integration/Routing/CompiledRouteCollectionTest.php +++ b/tests/Integration/Routing/CompiledRouteCollectionTest.php @@ -359,6 +359,92 @@ public function testMatchingWildcardFromCompiledRoutesAlwaysTakesPrecedent() $this->assertSame('foo', $routes->match(Request::create('/foo', 'GET'))->getName()); } + public function testCompiledMatchBindsPathParametersAndOriginalValues(): void + { + $this->routeCollection->add( + $this->newRoute('GET', '/users/{user}/posts/{post}', [ + 'uses' => 'FooController@index', + 'as' => 'posts.show', + ]) + ); + + $route = $this->collection()->match(Request::create('/users/12/posts/34', 'GET')); + + $this->assertSame(['user' => '12', 'post' => '34'], $route->parameters()); + $this->assertSame(['user' => '12', 'post' => '34'], $route->originalParameters()); + + $route->setParameter('user', 'changed'); + + $this->assertSame(['user' => 'changed', 'post' => '34'], $route->parameters()); + $this->assertSame(['user' => '12', 'post' => '34'], $route->originalParameters()); + } + + public function testCompiledMatchUsesAnOverriddenRouteBindMethod(): void + { + $router = new BindTrackingRouter($this->app->make('events'), $this->app); + $routes = new RouteCollection; + $routes->add($router->newRoute('GET', '/users/{user}', [ + 'uses' => 'FooController@index', + 'as' => 'users.show', + ])); + + $route = $routes + ->toCompiledRouteCollection($router, $this->app) + ->match(Request::create('/users/42', 'GET')); + + $this->assertInstanceOf(BindTrackingRoute::class, $route); + $this->assertSame(1, $route->bindCalls); + $this->assertSame(['user' => '42'], $route->parameters()); + } + + public function testCompiledMatchBindsDomainAndPathParameters(): void + { + $this->routeCollection->add( + $this->newRoute('GET', '/users/{user}', [ + 'uses' => 'FooController@index', + 'as' => 'tenant.users.show', + 'domain' => '{tenant}.example.com', + ]) + ); + + $route = $this->collection()->match( + Request::create('https://hypervel.example.com/users/42', 'GET') + ); + + $this->assertSame(['tenant' => 'hypervel', 'user' => '42'], $route->parameters()); + $this->assertSame(['tenant' => 'hypervel', 'user' => '42'], $route->originalParameters()); + } + + public function testCompiledMatchAppliesOptionalParameterDefaults(): void + { + $this->routeCollection->add( + $this->newRoute('GET', '/reports/{period?}', [ + 'uses' => 'FooController@index', + 'as' => 'reports.show', + ])->defaults('period', 'current') + ); + + $route = $this->collection()->match(Request::create('/reports', 'GET')); + + $this->assertSame(['period' => 'current'], $route->parameters()); + $this->assertSame(['period' => 'current'], $route->originalParameters()); + } + + public function testCompiledMatchAppliesDefaultsWithoutUriParameters(): void + { + $this->routeCollection->add( + $this->newRoute('GET', '/reports', [ + 'uses' => 'FooController@index', + 'as' => 'reports.index', + ])->defaults('format', 'summary') + ); + + $route = $this->collection()->match(Request::create('/reports', 'GET')); + + $this->assertSame(['format' => 'summary'], $route->parameters()); + $this->assertSame(['format' => 'summary'], $route->originalParameters()); + } + public function testMatchingDynamicallyAddedRoutesTakePrecedenceOverFallbackRoutes() { $this->routeCollection->add($this->fallbackRoute(['uses' => 'FooController@index'])); @@ -384,7 +470,10 @@ public function testMatchingFallbackRouteCatchesAll() $routes->add($this->newRoute('GET', '/bar/{id}', ['uses' => 'FooController@index', 'as' => 'bar'])); - $this->assertSame('fallback', $routes->match(Request::create('/baz/1', 'GET'))->getName()); + $route = $routes->match(Request::create('/baz/1', 'GET')); + + $this->assertSame('fallback', $route->getName()); + $this->assertSame(['fallbackPlaceholder' => 'baz/1'], $route->parameters()); } public function testMatchingCachedFallbackTakesPrecedenceOverDynamicFallback() @@ -592,3 +681,25 @@ protected function fallbackRoute(mixed $action): Route )->where($placeholder, '.*')->fallback(); } } + +class BindTrackingRouter extends Router +{ + public function newRoute(array|string $methods, string $uri, mixed $action): Route + { + return (new BindTrackingRoute($methods, $uri, $action)) + ->setRouter($this) + ->setContainer($this->container); + } +} + +class BindTrackingRoute extends Route +{ + public int $bindCalls = 0; + + public function bind(Request $request): static + { + ++$this->bindCalls; + + return parent::bind($request); + } +} From 580b620efbadbda4a4816800e18133dbf08ea13a Mon Sep 17 00:00:00 2001 From: Albert Chen Date: Mon, 17 Aug 2026 19:51:56 +0800 Subject: [PATCH 14/34] perf(http-server): skip completed worker start waits The worker-start coordinator is a one-way boot boundary, yet every request looked it up and yielded after the worker had already started. Remember the first completed wait on each Server instance so all later requests bypass the coordinator hot path. The flag is set only after yield() returns, so a request arriving during boot still waits for initialization and a failed or incomplete wait is never cached. --- src/http-server/src/Server.php | 10 +++++++++- tests/HttpServer/ServerTest.php | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/src/http-server/src/Server.php b/src/http-server/src/Server.php index 878e03648..38a79c46b 100644 --- a/src/http-server/src/Server.php +++ b/src/http-server/src/Server.php @@ -30,6 +30,11 @@ class Server implements OnRequestInterface, BootstrapsForServer protected ?EventDispatcherContract $event = null; + /** + * Whether this worker has observed the completed worker-start boundary. + */ + protected bool $workerStarted = false; + public function __construct( protected Container $container, ) { @@ -73,7 +78,10 @@ public function onRequest(SwooleRequest $swooleRequest, SwooleResponse $swooleRe $exception = null; try { - CoordinatorManager::until(Constants::WORKER_START)->yield(); + if (! $this->workerStarted) { + CoordinatorManager::until(Constants::WORKER_START)->yield(); + $this->workerStarted = true; + } // Capture the raw transport method before any Symfony method-override // processing. This avoids SuspiciousOperationException from malformed diff --git a/tests/HttpServer/ServerTest.php b/tests/HttpServer/ServerTest.php index 742556200..6ebcbc179 100644 --- a/tests/HttpServer/ServerTest.php +++ b/tests/HttpServer/ServerTest.php @@ -97,6 +97,38 @@ public function testOnRequestDelegatestoKernelAndSendsResponse(): void $server->onRequest($swooleRequest, $swooleResponse); } + public function testOnRequestWaitsForWorkerStartOnlyOnce(): void + { + CoordinatorManager::until(Constants::WORKER_START)->resume(); + + $kernel = m::mock(KernelContract::class); + $kernel->shouldReceive('handle') + ->twice() + ->with(m::type(Request::class)) + ->andReturn(new Response('OK')); + $kernel->shouldReceive('terminate')->twice(); + + $container = m::mock(Container::class); + $container->shouldReceive('bound')->with('events')->andReturn(false); + + $server = new Server($container); + $this->setKernel($server, $kernel); + + $swooleResponse = m::mock(SwooleResponse::class); + $swooleResponse->shouldReceive('status')->twice()->with(200)->andReturnTrue(); + $swooleResponse->shouldReceive('header')->withAnyArgs()->andReturnTrue(); + $swooleResponse->shouldReceive('end')->twice()->with('OK')->andReturnTrue(); + + wait(fn () => $server->onRequest($this->createSwooleRequest(), $swooleResponse)); + + CoordinatorManager::clear(Constants::WORKER_START); + + wait( + fn () => $server->onRequest($this->createSwooleRequest(), $swooleResponse), + timeout: 0.1 + ); + } + public function testOnRequestSetsRequestInContext(): void { CoordinatorManager::until(Constants::WORKER_START)->resume(); From 30a55646497db8406b09d06d2e3cce9fd5d55d10 Mon Sep 17 00:00:00 2001 From: Albert Chen Date: Mon, 17 Aug 2026 19:52:09 +0800 Subject: [PATCH 15/34] perf(foundation): skip trim exclusions for non-string input Numbers, booleans, arrays, and null can never be trimmed, but TrimStrings built the merged exclusion list and evaluated every exclusion pattern before checking their type. Return non-string values immediately and reserve exclusion matching for values that can actually reach Str::trim(). String exclusions, nested wildcard keys, static never-trim state, and ordinary string transformation keep their existing behavior. --- .../src/Http/Middleware/TrimStrings.php | 6 +- .../Http/Middleware/TrimStringsTest.php | 120 ++++++++++++++++++ 2 files changed, 125 insertions(+), 1 deletion(-) diff --git a/src/foundation/src/Http/Middleware/TrimStrings.php b/src/foundation/src/Http/Middleware/TrimStrings.php index a7c894a12..2a5f05346 100644 --- a/src/foundation/src/Http/Middleware/TrimStrings.php +++ b/src/foundation/src/Http/Middleware/TrimStrings.php @@ -55,9 +55,13 @@ public function handle(Request $request, Closure $next): mixed */ protected function transform(string $key, mixed $value): mixed { + if (! is_string($value)) { + return $value; + } + $except = array_merge($this->except, static::$neverTrim); - if ($this->shouldSkip($key, $except) || ! is_string($value)) { + if ($this->shouldSkip($key, $except)) { return $value; } diff --git a/tests/Foundation/Http/Middleware/TrimStringsTest.php b/tests/Foundation/Http/Middleware/TrimStringsTest.php index 9e3f21399..be3c600e8 100644 --- a/tests/Foundation/Http/Middleware/TrimStringsTest.php +++ b/tests/Foundation/Http/Middleware/TrimStringsTest.php @@ -11,6 +11,28 @@ class TrimStringsTest extends TestCase { + public function testNonStringValuesDoNotPerformExclusionMatching() + { + $middleware = new TrimStringsTrackingExclusionMatches; + $symfonyRequest = new SymfonyRequest([ + 'integer' => 123, + 'boolean' => true, + 'null' => null, + 'string' => ' value ', + ]); + $symfonyRequest->server->set('REQUEST_METHOD', 'GET'); + $request = Request::createFromBase($symfonyRequest); + + $middleware->handle($request, function (Request $request) { + $this->assertSame(123, $request->input('integer')); + $this->assertTrue($request->input('boolean')); + $this->assertNull($request->input('null')); + $this->assertSame('value', $request->input('string')); + }); + + $this->assertSame(1, $middleware->exclusionMatchCount); + } + public function testTrimStringsIgnoringExceptAttribute() { $middleware = new TrimStringsWithExceptAttribute; @@ -31,6 +53,65 @@ public function testTrimStringsIgnoringExceptAttribute() }); } + public function testTrimStringsSupportsExactAndWildcardExceptAttributes() + { + $middleware = new TrimStringsWithExactAndWildcardExceptAttributes; + $symfonyRequest = new SymfonyRequest([ + 'exact' => ' exact ', + 'other' => ' other ', + 'users' => [ + ['secret' => ' first ', 'name' => ' Taylor '], + ['secret' => ' second ', 'name' => ' Abigail '], + ], + ]); + $symfonyRequest->server->set('REQUEST_METHOD', 'GET'); + $request = Request::createFromBase($symfonyRequest); + + $middleware->handle($request, function (Request $request) { + $this->assertSame(' exact ', $request->input('exact')); + $this->assertSame('other', $request->input('other')); + $this->assertSame(' first ', $request->input('users.0.secret')); + $this->assertSame('Taylor', $request->input('users.0.name')); + $this->assertSame(' second ', $request->input('users.1.secret')); + $this->assertSame('Abigail', $request->input('users.1.name')); + }); + } + + public function testGlobalExceptAppliesToAnExistingMiddlewareInstance() + { + $middleware = new TrimStrings; + + $this->assertSame('value', $this->handle($middleware, ['token' => ' value '])->input('token')); + + TrimStrings::except('token'); + + $this->assertSame(' value ', $this->handle($middleware, ['token' => ' value '])->input('token')); + } + + public function testFlushStateAppliesToAnExistingMiddlewareInstance() + { + TrimStrings::except('token'); + + $middleware = new TrimStrings; + + $this->assertSame(' value ', $this->handle($middleware, ['token' => ' value '])->input('token')); + + TrimStrings::flushState(); + + $this->assertSame('value', $this->handle($middleware, ['token' => ' value '])->input('token')); + } + + public function testInstanceExceptChangesAreUsedBySubsequentRequests(): void + { + $middleware = new MutableExceptTrimStrings; + + $this->assertSame('value', $this->handle($middleware, ['token' => ' value '])->input('token')); + + $middleware->setExcept(['token']); + + $this->assertSame(' value ', $this->handle($middleware, ['token' => ' value '])->input('token')); + } + public function testTrimStringsNBSP() { $middleware = new TrimStrings; @@ -58,6 +139,17 @@ public function testTrimStringsNBSP() $this->assertSame("\xE9", $request->input('binary')); }); } + + private function handle(TrimStrings $middleware, array $input): Request + { + $symfonyRequest = new SymfonyRequest($input); + $symfonyRequest->server->set('REQUEST_METHOD', 'GET'); + $request = Request::createFromBase($symfonyRequest); + + $middleware->handle($request, fn (Request $request) => $request); + + return $request; + } } class TrimStringsWithExceptAttribute extends TrimStrings @@ -67,3 +159,31 @@ class TrimStringsWithExceptAttribute extends TrimStrings 'bar', ]; } + +class TrimStringsWithExactAndWildcardExceptAttributes extends TrimStrings +{ + protected array $except = [ + 'exact', + 'users.*.secret', + ]; +} + +class TrimStringsTrackingExclusionMatches extends TrimStrings +{ + public int $exclusionMatchCount = 0; + + protected function shouldSkip(string $key, array $except): bool + { + ++$this->exclusionMatchCount; + + return parent::shouldSkip($key, $except); + } +} + +class MutableExceptTrimStrings extends TrimStrings +{ + public function setExcept(array $except): void + { + $this->except = $except; + } +} From 48c97a004568139b6fe50bf2b0de35f3114ce085 Mon Sep 17 00:00:00 2001 From: Albert Chen Date: Mon, 17 Aug 2026 19:52:23 +0800 Subject: [PATCH 16/34] perf(support): fast path ASCII string trimming Request payloads are usually ASCII, but Str::trim() always ran a Unicode regex covering framework-specific invisible characters. Run native trim() first and return its result when both remaining boundaries prove that the Unicode cleanup cannot remove anything else. Form-feed boundaries, multibyte and invisible whitespace, explicit charlists, and invalid UTF-8 still use the original compatible paths. Together with the type-first TrimStrings dispatch, the measured string payload pass fell from about 186us to 34us. --- src/support/src/Str.php | 21 +++++++++++++++++---- tests/Support/SupportStrTest.php | 6 ++++++ 2 files changed, 23 insertions(+), 4 deletions(-) diff --git a/src/support/src/Str.php b/src/support/src/Str.php index 91f720f6c..31043e655 100644 --- a/src/support/src/Str.php +++ b/src/support/src/Str.php @@ -1325,13 +1325,26 @@ public static function snake(string $value, string $delimiter = '_'): string */ public static function trim(string $value, ?string $charlist = null): string { - if ($charlist === null) { - $trimDefaultCharacters = " \n\r\t\v\0"; + if ($charlist !== null) { + return trim($value, $charlist); + } - return preg_replace('~^[\s' . self::INVISIBLE_CHARACTERS . $trimDefaultCharacters . ']+|[\s' . self::INVISIBLE_CHARACTERS . $trimDefaultCharacters . ']+$~u', '', $value) ?? trim($value); + $trimmed = trim($value); + + if ($trimmed === '') { + return ''; } - return trim($value, $charlist); + if (ord($trimmed[0]) < 0x80 + && ord($trimmed[-1]) < 0x80 + && $trimmed[0] !== "\f" + && $trimmed[-1] !== "\f") { + return $trimmed; + } + + $trimDefaultCharacters = " \n\r\t\v\0"; + + return preg_replace('~^[\s' . self::INVISIBLE_CHARACTERS . $trimDefaultCharacters . ']+|[\s' . self::INVISIBLE_CHARACTERS . $trimDefaultCharacters . ']+$~u', '', $value) ?? $trimmed; } /** diff --git a/tests/Support/SupportStrTest.php b/tests/Support/SupportStrTest.php index d364d85ff..82f155dd6 100644 --- a/tests/Support/SupportStrTest.php +++ b/tests/Support/SupportStrTest.php @@ -1227,6 +1227,9 @@ public function testTrim(): void $this->assertSame('ム', Str::trim('ム')); $this->assertSame('だ', Str::trim('  だ   ')); $this->assertSame('ム', Str::trim('  ム   ')); + $this->assertSame('foo', Str::trim("\f foo \f")); + $this->assertSame('foo', Str::trim("\u{200B}foo\u{200B}")); + $this->assertSame('foo', Str::trim("\u{3000}foo\u{3000}")); $this->assertSame( 'foo bar', @@ -1244,6 +1247,9 @@ public function testTrim(): void ); $this->assertSame("\xE9", Str::trim(" \xE9 ")); + $this->assertSame("foo\xE9bar", Str::trim(" foo\xE9bar ")); + $this->assertSame("\xE9foo", Str::trim(" \xE9foo ")); + $this->assertSame("foo\xE9", Str::trim(" foo\xE9 ")); $trimDefaultChars = [' ', "\n", "\r", "\t", "\v", "\0"]; From e3ae76c280014accc3a84dea1bdebc63847a11b4 Mon Sep 17 00:00:00 2001 From: Albert Chen Date: Mon, 17 Aug 2026 19:52:34 +0800 Subject: [PATCH 17/34] perf(foundation): skip empty request transformations GET requests commonly present empty query, body, cookie, and file bags. The transform middleware still materialized each empty array, recursively cleaned it, and replaced the bag with another empty array. Use ParameterBag::count() to stop at the empty boundary. Non-empty bags and custom cleanArray()/transform() overrides continue through the original path. --- .../src/Http/Middleware/TransformsRequest.php | 4 ++ .../Http/Middleware/TransformsRequestTest.php | 51 +++++++++++++++++++ 2 files changed, 55 insertions(+) diff --git a/src/foundation/src/Http/Middleware/TransformsRequest.php b/src/foundation/src/Http/Middleware/TransformsRequest.php index b7cfe6b65..e149dcedc 100644 --- a/src/foundation/src/Http/Middleware/TransformsRequest.php +++ b/src/foundation/src/Http/Middleware/TransformsRequest.php @@ -39,6 +39,10 @@ protected function clean(Request $request): void */ protected function cleanParameterBag(ParameterBag $bag): void { + if ($bag->count() === 0) { + return; + } + $bag->replace($this->cleanArray($bag->all())); } diff --git a/tests/Foundation/Http/Middleware/TransformsRequestTest.php b/tests/Foundation/Http/Middleware/TransformsRequestTest.php index 99024b8f9..99f586774 100644 --- a/tests/Foundation/Http/Middleware/TransformsRequestTest.php +++ b/tests/Foundation/Http/Middleware/TransformsRequestTest.php @@ -7,10 +7,32 @@ use Hypervel\Foundation\Http\Middleware\TransformsRequest; use Hypervel\Http\Request; use Hypervel\Tests\TestCase; +use Symfony\Component\HttpFoundation\ParameterBag; use Symfony\Component\HttpFoundation\Request as SymfonyRequest; class TransformsRequestTest extends TestCase { + public function testEmptyParameterBagIsNotReadOrReplaced() + { + $bag = new TrackingParameterBag; + + (new ExposedTransformsRequest)->cleanBag($bag); + + $this->assertSame(0, $bag->allCalls); + $this->assertSame(0, $bag->replaceCalls); + } + + public function testNonEmptyParameterBagIsStillReadAndReplaced() + { + $bag = new TrackingParameterBag(['name' => 'Taylor']); + + (new ExposedTransformsRequest)->cleanBag($bag); + + $this->assertSame(1, $bag->allCalls); + $this->assertSame(1, $bag->replaceCalls); + $this->assertSame(['name' => 'Taylor'], $bag->all()); + } + public function testTransformOncePerKeyWhenMethodIsGet() { $middleware = new TruncateInput; @@ -134,3 +156,32 @@ protected function transform(string $key, mixed $value): mixed return substr($value, 0, -1); } } + +class ExposedTransformsRequest extends TransformsRequest +{ + public function cleanBag(ParameterBag $bag): void + { + $this->cleanParameterBag($bag); + } +} + +class TrackingParameterBag extends ParameterBag +{ + public int $allCalls = 0; + + public int $replaceCalls = 0; + + public function all(?string $key = null): array + { + ++$this->allCalls; + + return parent::all($key); + } + + public function replace(array $parameters = []): void + { + ++$this->replaceCalls; + + parent::replace($parameters); + } +} From bba256fd8e457465f6c16cc8e07d0abadfd096f5 Mon Sep 17 00:00:00 2001 From: Albert Chen Date: Mon, 17 Aug 2026 19:52:51 +0800 Subject: [PATCH 18/34] perf(routing): skip implicit binding without parameters A route with no bound parameters cannot contain an implicit model or enum binding, but the resolver still inspected its action and reflected the controller signature. Return at the already-materialized empty parameter list before doing any controller or callable work. Parameterized controller actions, callbacks, scoped bindings, backed enums, and missing-model behavior continue through the existing resolver. --- src/routing/src/ImplicitRouteBinding.php | 4 ++++ tests/Routing/ImplicitRouteBindingTest.php | 22 ++++++++++++++++++++++ tests/Routing/RoutingRouteTest.php | 20 ++++++++++++++++++++ 3 files changed, 46 insertions(+) diff --git a/src/routing/src/ImplicitRouteBinding.php b/src/routing/src/ImplicitRouteBinding.php index 918ccc1dc..c63edb996 100644 --- a/src/routing/src/ImplicitRouteBinding.php +++ b/src/routing/src/ImplicitRouteBinding.php @@ -54,6 +54,10 @@ public static function resolveForRoute(Container $container, Route $route): void { $parameters = $route->parameters(); + if ($parameters === []) { + return; + } + $action = $route->getAction('uses'); if (is_string($action)) { diff --git a/tests/Routing/ImplicitRouteBindingTest.php b/tests/Routing/ImplicitRouteBindingTest.php index 09f07bee1..e1f018477 100644 --- a/tests/Routing/ImplicitRouteBindingTest.php +++ b/tests/Routing/ImplicitRouteBindingTest.php @@ -19,6 +19,16 @@ class ImplicitRouteBindingTest extends RoutingTestCase { + public function testItDoesNotInspectTheActionWhenTheRouteHasNoParameters(): void + { + $route = new EmptyParameterRoute('GET', '/test', fn () => 'ok'); + $route->bind(Request::create('/test')); + + ImplicitRouteBinding::resolveForRoute(Container::getInstance(), $route); + + $this->assertSame(0, $route->signatureParameterCalls); + } + public function testItCanResolveTheImplicitBackedEnumRouteBindingsForTheGivenRoute(): void { $action = ['uses' => function (CategoryBackedEnum $category) { @@ -207,6 +217,18 @@ class ImplicitRouteBindingUser extends Model { } +class EmptyParameterRoute extends Route +{ + public int $signatureParameterCalls = 0; + + public function signatureParameters(array|string $conditions = []): array + { + ++$this->signatureParameterCalls; + + return parent::signatureParameters($conditions); + } +} + class ImplicitRouteBindingInvoker { public function __invoke(CategoryBackedEnum $category): string diff --git a/tests/Routing/RoutingRouteTest.php b/tests/Routing/RoutingRouteTest.php index 9fc40eb3b..d0857dc30 100644 --- a/tests/Routing/RoutingRouteTest.php +++ b/tests/Routing/RoutingRouteTest.php @@ -1792,6 +1792,26 @@ public function testImplicitBindingsWithClosure() $this->assertSame('otwell', $router->dispatch(Request::create('foo/taylor', 'GET'))->getContent()); } + public function testCustomImplicitBindingCallbackRunsForRouteWithoutParameters() + { + $router = $this->getRouter(); + $calls = 0; + + $router->substituteImplicitBindingsUsing(function ($container, $route, $default) use (&$calls) { + ++$calls; + + return $default(); + }); + + $router->get('foo', [ + 'middleware' => SubstituteBindings::class, + 'uses' => fn () => 'ok', + ]); + + $this->assertSame('ok', $router->dispatch(Request::create('foo', 'GET'))->getContent()); + $this->assertSame(1, $calls); + } + public function testImplicitBindingsWhereScopedBindingsArePrevented() { $router = $this->getRouter(); From 472175064e53d34a6b400504af70cd0650271abc Mon Sep 17 00:00:00 2001 From: Albert Chen Date: Mon, 17 Aug 2026 19:53:07 +0800 Subject: [PATCH 19/34] perf(http): reuse request subjects across path patterns Each CORS or exclusion pattern called fullUrlIs() and is(), rebuilding the same full URL and decoded path for every iteration. Materialize each request subject lazily once and apply Str::is() directly to all configured patterns. The full URL is still checked before the decoded path for every pattern, so short-circuit order, host-qualified patterns, wildcards, and root paths retain their previous matching semantics. --- .../Middleware/Concerns/ExcludesPaths.php | 7 ++- src/http/src/Middleware/HandleCors.php | 6 ++- .../Middleware/Concerns/ExcludesPathsTest.php | 41 +++++++++++++++ tests/Http/Middleware/HandleCorsTest.php | 51 +++++++++++++++++++ 4 files changed, 103 insertions(+), 2 deletions(-) diff --git a/src/foundation/src/Http/Middleware/Concerns/ExcludesPaths.php b/src/foundation/src/Http/Middleware/Concerns/ExcludesPaths.php index 2b5202e48..759d7f4d9 100644 --- a/src/foundation/src/Http/Middleware/Concerns/ExcludesPaths.php +++ b/src/foundation/src/Http/Middleware/Concerns/ExcludesPaths.php @@ -5,6 +5,7 @@ namespace Hypervel\Foundation\Http\Middleware\Concerns; use Hypervel\Http\Request; +use Hypervel\Support\Str; trait ExcludesPaths { @@ -13,12 +14,16 @@ trait ExcludesPaths */ protected function inExceptArray(Request $request): bool { + $fullUrl = null; + $decodedPath = null; + foreach ($this->getExcludedPaths() as $except) { if ($except !== '/') { $except = trim($except, '/'); } - if ($request->fullUrlIs($except) || $request->is($except)) { + if (Str::is($except, $fullUrl ??= $request->fullUrl()) + || Str::is($except, $decodedPath ??= $request->decodedPath())) { return true; } } diff --git a/src/http/src/Middleware/HandleCors.php b/src/http/src/Middleware/HandleCors.php index 4ec26e2ef..c796823f5 100644 --- a/src/http/src/Middleware/HandleCors.php +++ b/src/http/src/Middleware/HandleCors.php @@ -8,6 +8,7 @@ use Fruitcake\Cors\CorsService; use Hypervel\Contracts\Container\Container; use Hypervel\Http\Request; +use Hypervel\Support\Str; use Symfony\Component\HttpFoundation\Response; class HandleCors @@ -84,13 +85,16 @@ public function handle(Request $request, Closure $next): Response protected function hasMatchingPath(Request $request, array $paths): bool { $paths = $this->getPathsByHost($request->getHost(), $paths); + $fullUrl = null; + $decodedPath = null; foreach ($paths as $path) { if ($path !== '/') { $path = trim($path, '/'); } - if ($request->fullUrlIs($path) || $request->is($path)) { + if (Str::is($path, $fullUrl ??= $request->fullUrl()) + || Str::is($path, $decodedPath ??= $request->decodedPath())) { return true; } } diff --git a/tests/Foundation/Http/Middleware/Concerns/ExcludesPathsTest.php b/tests/Foundation/Http/Middleware/Concerns/ExcludesPathsTest.php index 230ba9337..76c6dd98f 100644 --- a/tests/Foundation/Http/Middleware/Concerns/ExcludesPathsTest.php +++ b/tests/Foundation/Http/Middleware/Concerns/ExcludesPathsTest.php @@ -39,6 +39,26 @@ public function testEmptyExclusionListMatchesNothing(): void $this->assertFalse($excluder->check(Request::create('http://example.com/up'))); } + public function testUrlAndPathAreResolvedOnceAcrossMultiplePatterns(): void + { + $request = CountingRequest::create('http://example.com/api/users'); + $excluder = new ExcludesPathsTestExcluder(['missing', 'also-missing', 'api/*']); + + $this->assertTrue($excluder->check($request)); + $this->assertSame(1, $request->fullUrlCalls); + $this->assertSame(1, $request->decodedPathCalls); + } + + public function testFullUrlMatchDoesNotResolveTheDecodedPath(): void + { + $request = CountingRequest::create('http://example.com/admin/users'); + $excluder = new ExcludesPathsTestExcluder(['http://example.com/admin/*']); + + $this->assertTrue($excluder->check($request)); + $this->assertSame(1, $request->fullUrlCalls); + $this->assertSame(0, $request->decodedPathCalls); + } + public function testUntrustedHostIsRejectedEvenWhenThePathIsExcluded(): void { $request = Request::create('http://evil.com/up'); @@ -104,3 +124,24 @@ public function check(Request $request): bool return $this->inExceptArray($request); } } + +class CountingRequest extends Request +{ + public int $fullUrlCalls = 0; + + public int $decodedPathCalls = 0; + + public function fullUrl(): string + { + ++$this->fullUrlCalls; + + return parent::fullUrl(); + } + + public function decodedPath(): string + { + ++$this->decodedPathCalls; + + return parent::decodedPath(); + } +} diff --git a/tests/Http/Middleware/HandleCorsTest.php b/tests/Http/Middleware/HandleCorsTest.php index bdb236fc1..75a5d33df 100644 --- a/tests/Http/Middleware/HandleCorsTest.php +++ b/tests/Http/Middleware/HandleCorsTest.php @@ -347,6 +347,36 @@ public function testPathsWrittenAsAbsoluteUrlsDoNotMatchOtherPaths(): void $this->assertNull($response->headers->get('Access-Control-Allow-Origin')); } + public function testPathSubjectsAreResolvedOnceAcrossMultipleCorsPatterns(): void + { + $request = CountingCorsRequest::create('http://localhost/api/ping', 'OPTIONS'); + $request->headers->set('Origin', 'http://localhost'); + $request->headers->set('Access-Control-Request-Method', 'POST'); + + $response = $this->makeMiddleware([ + 'paths' => ['missing', 'also-missing', 'api/*'], + ])->handle($request, fn () => new Response('', 200)); + + $this->assertSame('http://localhost', $response->headers->get('Access-Control-Allow-Origin')); + $this->assertSame(1, $request->fullUrlCalls); + $this->assertSame(1, $request->decodedPathCalls); + } + + public function testAbsoluteCorsUrlMatchDoesNotResolveTheDecodedPath(): void + { + $request = CountingCorsRequest::create('http://localhost/admin/ping', 'OPTIONS'); + $request->headers->set('Origin', 'http://localhost'); + $request->headers->set('Access-Control-Request-Method', 'POST'); + + $response = $this->makeMiddleware([ + 'paths' => ['http://localhost/admin/*'], + ])->handle($request, fn () => new Response('', 200)); + + $this->assertSame('http://localhost', $response->headers->get('Access-Control-Allow-Origin')); + $this->assertSame(1, $request->fullUrlCalls); + $this->assertSame(0, $request->decodedPathCalls); + } + protected function makeRequest(string $method, string $path, array $headers = []): Request { $request = Request::create('http://localhost/' . ltrim($path, '/'), $method); @@ -374,3 +404,24 @@ protected function dispatchRequest(string $method, string $path, array $headers, ); } } + +class CountingCorsRequest extends Request +{ + public int $fullUrlCalls = 0; + + public int $decodedPathCalls = 0; + + public function fullUrl(): string + { + ++$this->fullUrlCalls; + + return parent::fullUrl(); + } + + public function decodedPath(): string + { + ++$this->decodedPathCalls; + + return parent::decodedPath(); + } +} From 3cf013db1d0679abf9cd3b52137006587c009ee8 Mon Sep 17 00:00:00 2001 From: Albert Chen Date: Mon, 17 Aug 2026 19:53:25 +0800 Subject: [PATCH 20/34] perf(pipeline): cache parsed route middleware descriptors Route middleware strings are stable after resolution, but Pipeline split the class name and parameters again on every request. Parse class strings into an immutable PipeDescriptor once per resolved route and cache that descriptor list alongside the route's middleware cache. Pipeline still resolves middleware instances from the container per request and honors parameters and custom via() methods. Callable, object, dynamically changed, disabled, serialized, and container-rebound middleware retain their uncached or invalidated paths. --- src/pipeline/src/PipeDescriptor.php | 35 ++++++++++ src/pipeline/src/Pipeline.php | 17 +++-- src/routing/src/Route.php | 10 +++ src/routing/src/Router.php | 27 +++++++- tests/Pipeline/PipelineTest.php | 52 ++++++++++++++ tests/Routing/RouteMiddlewareCachingTest.php | 71 ++++++++++++++++++++ 6 files changed, 206 insertions(+), 6 deletions(-) create mode 100644 src/pipeline/src/PipeDescriptor.php diff --git a/src/pipeline/src/PipeDescriptor.php b/src/pipeline/src/PipeDescriptor.php new file mode 100644 index 000000000..d7433d363 --- /dev/null +++ b/src/pipeline/src/PipeDescriptor.php @@ -0,0 +1,35 @@ + $parameters + */ + public function __construct( + public string $name, + public array $parameters = [], + public ?string $method = null, + ) { + } + + /** + * Create a descriptor from a pipeline string. + */ + public static function fromString(string $pipe, ?string $method = null): self + { + if (! str_contains($pipe, ':')) { + return new self($pipe, method: $method); + } + + [$name, $parameters] = explode(':', $pipe, 2); + + return new self($name, explode(',', $parameters), $method); + } +} diff --git a/src/pipeline/src/Pipeline.php b/src/pipeline/src/Pipeline.php index 766089eca..172dbb10b 100644 --- a/src/pipeline/src/Pipeline.php +++ b/src/pipeline/src/Pipeline.php @@ -160,13 +160,20 @@ protected function carry(): Closure return function ($stack, $pipe) { return function ($passable) use ($stack, $pipe) { try { - if (is_callable($pipe)) { + $method = $this->method; + + if ($pipe instanceof PipeDescriptor) { + $parameters = $pipe->parameters === [] + ? [$passable, $stack] + : array_merge([$passable, $stack], $pipe->parameters); + $method = $pipe->method ?? $method; + $pipe = $this->getContainer()->make($pipe->name); + } elseif (is_callable($pipe)) { // If the pipe is a callable, then we will call it directly, but otherwise we // will resolve the pipes out of the dependency container and call it with // the appropriate method and arguments, returning the results back out. return $pipe($passable, $stack); - } - if (! is_object($pipe)) { + } elseif (! is_object($pipe)) { // Only pipes written as 'name:arg,arg' carry parameters, so the // parse is skipped for the common parameterless case. if (str_contains($pipe, ':')) { @@ -188,8 +195,8 @@ protected function carry(): Closure $parameters = [$passable, $stack]; } - $carry = method_exists($pipe, $this->method) - ? $pipe->{$this->method}(...$parameters) + $carry = method_exists($pipe, $method) + ? $pipe->{$method}(...$parameters) : $pipe(...$parameters); return $this->handleCarry($carry); diff --git a/src/routing/src/Route.php b/src/routing/src/Route.php index c431492cb..344469a37 100755 --- a/src/routing/src/Route.php +++ b/src/routing/src/Route.php @@ -138,6 +138,13 @@ class Route */ public ?array $resolvedMiddleware = null; + /** + * The cached pipeline descriptors for resolved class-string middleware. + * + * @var null|array + */ + public ?array $middlewareDescriptors = null; + /** * The compiled version of the route. * @@ -398,6 +405,7 @@ public function flushController(): void { $this->computedMiddleware = null; $this->controller = null; + $this->middlewareDescriptors = null; $this->resolvedMiddleware = null; if ($this->isControllerAction()) { @@ -1535,6 +1543,7 @@ public function setContainer(Container $container): static $this->computedMiddleware = null; $this->controller = null; $this->controllerDispatcher = null; + $this->middlewareDescriptors = null; $this->resolvedMiddleware = null; $this->shouldCacheControllerOnRoute = null; @@ -1569,6 +1578,7 @@ public function prepareForSerialization(): void $this->container = null; $this->controller = null; $this->controllerDispatcher = null; + $this->middlewareDescriptors = null; $this->missing = null; $this->resolvedMiddleware = null; $this->router = null; diff --git a/src/routing/src/Router.php b/src/routing/src/Router.php index 58c2bc86d..3e5fc6861 100644 --- a/src/routing/src/Router.php +++ b/src/routing/src/Router.php @@ -18,6 +18,7 @@ use Hypervel\Http\JsonResponse; use Hypervel\Http\Request; use Hypervel\Http\Response; +use Hypervel\Pipeline\PipeDescriptor; use Hypervel\Pipeline\Pipeline as BasePipeline; use Hypervel\Routing\Events\PreparingResponse; use Hypervel\Routing\Events\ResponsePrepared; @@ -713,7 +714,31 @@ protected function middlewareFor(Route $route): array $disabled = $this->container->bound('middleware.disable') && $this->container->make('middleware.disable') === true; - return $disabled ? [] : $this->gatherRouteMiddleware($route); + if ($disabled) { + return []; + } + + $middleware = $this->gatherRouteMiddleware($route); + + if ($middleware === []) { + return []; + } + + if ($middleware !== $route->resolvedMiddleware) { + return array_map( + static fn (mixed $pipe): mixed => is_string($pipe) && ! is_callable($pipe) + ? PipeDescriptor::fromString($pipe) + : $pipe, + $middleware + ); + } + + return $route->middlewareDescriptors ??= array_map( + static fn (mixed $pipe): mixed => is_string($pipe) && ! is_callable($pipe) + ? PipeDescriptor::fromString($pipe) + : $pipe, + $middleware + ); } /** diff --git a/tests/Pipeline/PipelineTest.php b/tests/Pipeline/PipelineTest.php index cb1d0a411..0d5350846 100644 --- a/tests/Pipeline/PipelineTest.php +++ b/tests/Pipeline/PipelineTest.php @@ -9,6 +9,7 @@ use Hypervel\Container\Container; use Hypervel\Database\Connection; use Hypervel\Database\DatabaseManager; +use Hypervel\Pipeline\PipeDescriptor; use Hypervel\Pipeline\Pipeline; use Hypervel\Tests\Pipeline\Fixtures\FooPipeline; use Hypervel\Tests\TestCase; @@ -237,6 +238,57 @@ public function testPipelineUsageWithParameters(): void unset($_SERVER['__test.pipe.parameters']); } + public function testPipelineUsageWithAnImmutablePipeDescriptor(): void + { + $descriptor = PipeDescriptor::fromString(PipelineTestParameterPipe::class . ':one,two'); + + $result = (new Pipeline(new Container)) + ->send('foo') + ->through($descriptor) + ->then(fn ($piped) => $piped); + + $this->assertSame('foo', $result); + $this->assertSame(['one', 'two'], $_SERVER['__test.pipe.parameters']); + + unset($_SERVER['__test.pipe.parameters']); + } + + public function testPipeDescriptorUsesThePipelineMethodWhenNoneIsSpecified(): void + { + $result = (new Pipeline(new Container))->send('data') + ->through(PipeDescriptor::fromString(PipelineTestPartialMethodPipe::class)) + ->via('missingMethod') + ->then(fn (mixed $piped): mixed => $piped); + + $this->assertSame('data:invoked', $result); + } + + public function testPipeDescriptorPreservesLazyContainerResolutionAndCallableFallback(): void + { + $container = new Container; + $container->bind(PipelineTestUnreachablePipe::class); + $container->bind(PipelineTestPipeOne::class, fn () => new PipelineTestPipeTwo); + + $result = (new Pipeline($container))->send('data') + ->through([ + new PipeDescriptor(PipelineTestShortCircuitPipe::class), + new PipeDescriptor(PipelineTestUnreachablePipe::class), + ]) + ->then(fn (mixed $piped): mixed => $piped); + + $this->assertSame('short-circuited', $result); + $this->assertArrayNotHasKey('__test.pipe.unreachable', $_SERVER); + + $result = (new Pipeline($container))->send('foo') + ->through(new PipeDescriptor(PipelineTestPipeOne::class)) + ->then(fn (mixed $piped): mixed => $piped); + + $this->assertSame('foo', $result); + $this->assertSame('foo', $_SERVER['__test.pipe.one']); + + unset($_SERVER['__test.pipe.one']); + } + public function testPipelineViaChangesTheMethodBeingCalledOnThePipes(): void { $pipelineInstance = new Pipeline(new Container); diff --git a/tests/Routing/RouteMiddlewareCachingTest.php b/tests/Routing/RouteMiddlewareCachingTest.php index c2cc39564..43074d49c 100644 --- a/tests/Routing/RouteMiddlewareCachingTest.php +++ b/tests/Routing/RouteMiddlewareCachingTest.php @@ -9,11 +9,13 @@ use Hypervel\Contracts\Routing\Registrar; use Hypervel\Events\Dispatcher; use Hypervel\Http\Request; +use Hypervel\Pipeline\PipeDescriptor; use Hypervel\Routing\CallableDispatcher; use Hypervel\Routing\Contracts\CallableDispatcher as CallableDispatcherContract; use Hypervel\Routing\Contracts\ControllerDispatcher as ControllerDispatcherContract; use Hypervel\Routing\Controller; use Hypervel\Routing\ControllerDispatcher; +use Hypervel\Routing\Route; use Hypervel\Routing\Router; use Hypervel\Tests\Routing\RoutingTestCase; @@ -36,6 +38,47 @@ public function testResolvedMiddlewareIsCachedOnRoute(): void $this->assertNotNull($route->resolvedMiddleware); } + public function testRouteDispatchCachesDescriptorsWithoutChangingGatheredMiddleware(): void + { + $router = $this->getRouter(); + $route = $router->get('foo', [ + 'middleware' => TestMiddleware::class, + 'uses' => fn () => 'ok', + ]); + + $response = $router->dispatch(Request::create('foo', 'GET')); + + $this->assertSame('ok', $response->getContent()); + $this->assertSame([TestMiddleware::class], $router->gatherRouteMiddleware($route)); + $this->assertContainsOnlyInstancesOf(PipeDescriptor::class, $route->middlewareDescriptors); + $this->assertSame(TestMiddleware::class, $route->middlewareDescriptors[0]->name); + } + + public function testRouteWithoutMiddlewareDoesNotBuildDescriptors(): void + { + $router = $this->getRouter(); + $route = $router->get('foo', fn () => 'ok'); + + $response = $router->dispatch(Request::create('foo', 'GET')); + + $this->assertSame('ok', $response->getContent()); + $this->assertNull($route->middlewareDescriptors); + } + + public function testDynamicGatheredMiddlewareIsNotReplacedByCachedDescriptors(): void + { + $container = new Container; + $router = new DynamicMiddlewareRouter(new Dispatcher($container), $container); + $route = $router->get('foo', fn () => 'ok'); + + $first = $router->middlewareForRoute($route); + $second = $router->middlewareForRoute($route); + + $this->assertSame(TestMiddleware::class, $first[0]->name); + $this->assertSame(SecondTestMiddleware::class, $second[0]->name); + $this->assertNull($route->middlewareDescriptors); + } + public function testResolvedMiddlewareIsNullBeforeGathering(): void { $router = $this->getRouter(); @@ -56,10 +99,12 @@ public function testFlushControllerClearsResolvedMiddleware(): void }]); $router->gatherRouteMiddleware($route); + $route->middlewareDescriptors = [new PipeDescriptor(TestMiddleware::class)]; $this->assertNotNull($route->resolvedMiddleware); $route->flushController(); + $this->assertNull($route->middlewareDescriptors); $this->assertNull($route->resolvedMiddleware); } @@ -72,10 +117,12 @@ public function testPrepareForSerializationClearsResolvedMiddleware(): void }]); $router->gatherRouteMiddleware($route); + $route->middlewareDescriptors = [new PipeDescriptor(TestMiddleware::class)]; $this->assertNotNull($route->resolvedMiddleware); $route->prepareForSerialization(); + $this->assertNull($route->middlewareDescriptors); $this->assertNull($route->resolvedMiddleware); } @@ -113,9 +160,12 @@ public function testSettingTheSameContainerPreservesResolvedMiddleware(): void $router->gatherRouteMiddleware($route); $resolvedMiddleware = $route->resolvedMiddleware; + $route->middlewareDescriptors = [new PipeDescriptor(TestMiddleware::class)]; + $middlewareDescriptors = $route->middlewareDescriptors; $route->setContainer($container); + $this->assertSame($middlewareDescriptors, $route->middlewareDescriptors); $this->assertSame($resolvedMiddleware, $route->resolvedMiddleware); $this->assertSame($resolvedMiddleware, $router->gatherRouteMiddleware($route)); $this->assertSame(1, $dispatcherResolutions); @@ -130,12 +180,14 @@ public function testSettingADifferentContainerClearsResolvedMiddleware(): void }]); $router->gatherRouteMiddleware($route); + $route->middlewareDescriptors = [new PipeDescriptor(TestMiddleware::class)]; $this->assertNotNull($route->computedMiddleware); $this->assertNotNull($route->resolvedMiddleware); $route->setContainer(new Container); $this->assertNull($route->computedMiddleware); + $this->assertNull($route->middlewareDescriptors); $this->assertNull($route->resolvedMiddleware); } @@ -162,6 +214,25 @@ public function handle(mixed $request, Closure $next): mixed } } +class SecondTestMiddleware extends TestMiddleware +{ +} + +class DynamicMiddlewareRouter extends Router +{ + protected int $gatherCalls = 0; + + public function gatherRouteMiddleware(Route $route): array + { + return [++$this->gatherCalls === 1 ? TestMiddleware::class : SecondTestMiddleware::class]; + } + + public function middlewareForRoute(Route $route): array + { + return $this->middlewareFor($route); + } +} + class MiddlewareController extends Controller { public function __construct() From 7a08b4be6ecd6f05cd42fedf898d5db9e3f3baeb Mon Sep 17 00:00:00 2001 From: Albert Chen Date: Mon, 17 Aug 2026 21:33:05 +0800 Subject: [PATCH 21/34] perf(http-server): optimize common header normalization Swoole supplies lowercase header names, while RequestBridge rebuilt every HTTP_* key with str_replace() and strtoupper(). Map the headers present on most requests directly and keep the generic normalization path for uncommon or mixed-case names. Four-header normalization fell from about 1.03us to 0.85us and an eight-header case from 1.45us to 1.01us. Unknown headers keep the same $_SERVER spelling. --- src/http-server/src/RequestBridge.php | 17 ++++++++++++++++- tests/HttpServer/RequestBridgeTest.php | 16 ++++++++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/src/http-server/src/RequestBridge.php b/src/http-server/src/RequestBridge.php index 675b6140e..3b6cf41a1 100644 --- a/src/http-server/src/RequestBridge.php +++ b/src/http-server/src/RequestBridge.php @@ -62,7 +62,22 @@ protected static function transformServerParams(array $server, array $headers): // Swoole headers → HTTP_* format foreach ($headers as $key => $value) { - $httpKey = 'HTTP_' . strtoupper(str_replace('-', '_', $key)); + $httpKey = match ($key) { + 'accept' => 'HTTP_ACCEPT', + 'accept-encoding' => 'HTTP_ACCEPT_ENCODING', + 'authorization' => 'HTTP_AUTHORIZATION', + 'connection' => 'HTTP_CONNECTION', + 'content-length' => 'HTTP_CONTENT_LENGTH', + 'content-type' => 'HTTP_CONTENT_TYPE', + 'host' => 'HTTP_HOST', + 'user-agent' => 'HTTP_USER_AGENT', + 'x-forwarded-for' => 'HTTP_X_FORWARDED_FOR', + 'x-forwarded-host' => 'HTTP_X_FORWARDED_HOST', + 'x-forwarded-port' => 'HTTP_X_FORWARDED_PORT', + 'x-forwarded-proto' => 'HTTP_X_FORWARDED_PROTO', + 'x-request-id' => 'HTTP_X_REQUEST_ID', + default => 'HTTP_' . strtoupper(str_replace('-', '_', $key)), + }; $result[$httpKey] = $value; } diff --git a/tests/HttpServer/RequestBridgeTest.php b/tests/HttpServer/RequestBridgeTest.php index 9b3ba15c2..2418ee4e3 100644 --- a/tests/HttpServer/RequestBridgeTest.php +++ b/tests/HttpServer/RequestBridgeTest.php @@ -151,6 +151,22 @@ public function testHeadersGetHttpPrefix(): void $this->assertSame('Bearer token123', $request->headers->get('authorization')); } + public function testMixedCaseAndUnknownHeadersUseGenericNormalization(): void + { + $swooleRequest = $this->createSwooleRequest( + server: ['request_method' => 'get', 'request_uri' => '/'], + header: [ + 'User-Agent' => 'custom-client', + 'X-Custom-Mixed-Header' => 'custom-value', + ], + ); + + $request = RequestBridge::createFromSwoole($swooleRequest); + + $this->assertSame('custom-client', $request->headers->get('user-agent')); + $this->assertSame('custom-value', $request->headers->get('x-custom-mixed-header')); + } + public function testContentTypeAndContentLengthGetSpecialTreatment(): void { $swooleRequest = $this->createSwooleRequest( From 1d2ec46734ea2092df68b579eb5213ba0e5a2498 Mon Sep 17 00:00:00 2001 From: Albert Chen Date: Mon, 17 Aug 2026 23:38:10 +0800 Subject: [PATCH 22/34] perf(http): clone a header bag prototype when building responses Most of the cost of a plain Response is Symfony's ResponseHeaderBag constructor, which sets an empty Cache-Control value only to parse it through the full cache directive regex. Build that invariant bag once per worker and clone it for each response; cloning costs about 0.07us instead of roughly 4.1us. The clone is passed to SymfonyResponse::__construct, so response state remains independent and the deprecated property setter is avoided. Date is refreshed before caller headers are added, allowing an explicit Date to keep precedence. --- src/http/src/Response.php | 26 ++++++++++++++++++++++---- 1 file changed, 22 insertions(+), 4 deletions(-) diff --git a/src/http/src/Response.php b/src/http/src/Response.php index e6ba9670a..5515e3c64 100755 --- a/src/http/src/Response.php +++ b/src/http/src/Response.php @@ -15,6 +15,7 @@ use RuntimeException; use Stringable; use Symfony\Component\HttpFoundation\Response as SymfonyResponse; +use Symfony\Component\HttpFoundation\ResponseHeaderBag; class Response extends SymfonyResponse { @@ -23,6 +24,11 @@ class Response extends SymfonyResponse } use ResponseTrait; + /** + * The pristine header bag cloned for each response. + */ + protected static ?ResponseHeaderBag $headerPrototype = null; + /** * Create a new HTTP response. * @@ -30,9 +36,17 @@ class Response extends SymfonyResponse */ public function __construct(mixed $content = '', int $status = 200, array $headers = []) { - // The parent constructor accepts the headers since Symfony 8.1; assigning - // the property directly would hit the deprecated property setter. - parent::__construct('', $status, $headers); + $bag = clone (static::$headerPrototype ??= new ResponseHeaderBag); + + // A cloned bag carries the prototype's timestamp. Refresh it before + // adding the given headers so a caller-supplied Date still wins. + $bag->set('Date', gmdate('D, d M Y H:i:s') . ' GMT'); + + if ($headers !== []) { + $bag->add($headers); + } + + SymfonyResponse::__construct('', $status, $bag); $this->setContent($content); } @@ -43,7 +57,9 @@ public function __construct(mixed $content = '', int $status = 200, array $heade #[Override] public function getContent(): string|false { - return transform(parent::getContent(), fn ($content) => $content, ''); + $content = parent::getContent(); + + return $content === false ? '' : $content; } /** @@ -153,5 +169,7 @@ public function send(bool $flush = true): static public static function flushState(): void { static::flushMacros(); + + static::$headerPrototype = null; } } From 4e741ebc96fb5bc068be5437695ea38c8b4f7fb2 Mon Sep 17 00:00:00 2001 From: Albert Chen Date: Tue, 18 Aug 2026 09:33:18 +0800 Subject: [PATCH 23/34] perf(context): bypass enum conversion for string keys Framework request state uses string context identifiers almost exclusively, but every set(), get(), has(), and forget() call entered enum_value() and paid its enum-or-scalar dispatch. Recognize strings at the context boundary and call the helper only for actual UnitEnum identifiers. Backed and unit enum keys retain the same normalization, while the common string-key path avoids an extra helper call on every context access. --- src/context/src/CoroutineContext.php | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/context/src/CoroutineContext.php b/src/context/src/CoroutineContext.php index 3de978858..876856676 100644 --- a/src/context/src/CoroutineContext.php +++ b/src/context/src/CoroutineContext.php @@ -32,7 +32,7 @@ class CoroutineContext */ public static function set(UnitEnum|string $id, mixed $value, ?int $coroutineId = null): mixed { - $id = enum_value($id); + $id = is_string($id) ? $id : enum_value($id); $context = Coroutine::getContextFor($coroutineId); if ($context !== null) { @@ -54,7 +54,7 @@ public static function set(UnitEnum|string $id, mixed $value, ?int $coroutineId */ public static function get(UnitEnum|string $id, mixed $default = null, ?int $coroutineId = null): mixed { - $id = enum_value($id); + $id = is_string($id) ? $id : enum_value($id); $context = Coroutine::getContextFor($coroutineId); if ($context !== null) { @@ -73,7 +73,7 @@ public static function get(UnitEnum|string $id, mixed $default = null, ?int $cor */ public static function has(UnitEnum|string $id, ?int $coroutineId = null): bool { - $id = enum_value($id); + $id = is_string($id) ? $id : enum_value($id); $context = Coroutine::getContextFor($coroutineId); if ($context !== null) { @@ -90,7 +90,7 @@ public static function has(UnitEnum|string $id, ?int $coroutineId = null): bool */ public static function forget(UnitEnum|string $id, ?int $coroutineId = null): void { - $id = enum_value($id); + $id = is_string($id) ? $id : enum_value($id); $context = Coroutine::getContextFor($coroutineId); if ($context !== null) { From 010770101f2b6572a994c4f1789ddc53ab7a9668 Mon Sep 17 00:00:00 2001 From: Albert Chen Date: Tue, 18 Aug 2026 09:33:59 +0800 Subject: [PATCH 24/34] perf(http): reuse normalized request headers and transport paths RequestBridge already receives separated Swoole headers and a normalized URI, but Symfony rebuilt the headers from server variables and lazily derived the same path during routing. Build a HeaderBag directly from the transport data, install the request bags without property-hook overhead, and seed the raw path. Authorization aliases, content headers, cache directives, uploads, and request timestamps preserve Symfony behavior. The path is validated on first use so a middleware URI rewrite falls back to Symfony, as do front-controller and IIS server layouts. The isolated path boundary drops from 3.16us to 2.02us. --- src/http-server/src/RequestBridge.php | 88 +++++++++++++++- src/http-server/src/RequestHeaderBag.php | 31 ++++++ src/http/src/Request.php | 127 +++++++++++++++++++++++ tests/HttpServer/RequestBridgeTest.php | 124 +++++++++++++++++++++- 4 files changed, 368 insertions(+), 2 deletions(-) create mode 100644 src/http-server/src/RequestHeaderBag.php diff --git a/src/http-server/src/RequestBridge.php b/src/http-server/src/RequestBridge.php index 3b6cf41a1..d65521e27 100644 --- a/src/http-server/src/RequestBridge.php +++ b/src/http-server/src/RequestBridge.php @@ -33,6 +33,7 @@ public static function createFromSwoole(SwooleRequest $swooleRequest): Request $server = static::normalizeTrailingSlash($server); $content = $swooleRequest->rawContent(); + $headers = static::transformHeaders($swooleRequest->header ?? [], $server); return new Request( query: $swooleRequest->get ?? [], @@ -41,10 +42,67 @@ public static function createFromSwoole(SwooleRequest $swooleRequest): Request cookies: $swooleRequest->cookie ?? [], files: static::transformFiles($swooleRequest->files ?? []), server: $server, - content: $content === false ? null : $content + content: $content === false ? null : $content, + headers: $headers, + pathInfo: static::extractPathInfo($server), ); } + /** + * Build request headers directly while retaining Symfony's authorization aliases. + */ + protected static function transformHeaders(array $headers, array &$server): RequestHeaderBag + { + $headerBag = new RequestHeaderBag($headers); + + foreach (['CONTENT_TYPE', 'CONTENT_LENGTH', 'CONTENT_MD5'] as $name) { + if (! $headerBag->has($name) && isset($server[$name]) && $server[$name] !== '') { + $headerBag->set($name, $server[$name]); + } + } + + if (isset($server['PHP_AUTH_USER'])) { + $headerBag->set('PHP_AUTH_USER', $server['PHP_AUTH_USER']); + $headerBag->set('PHP_AUTH_PW', $server['PHP_AUTH_PW'] ?? ''); + } else { + $authorization = $server['HTTP_AUTHORIZATION'] + ?? $server['REDIRECT_HTTP_AUTHORIZATION'] + ?? null; + + if ($authorization !== null && stripos($authorization, 'basic ') === 0) { + $credentials = explode(':', (string) base64_decode(substr($authorization, 6)), 2); + + if (count($credentials) === 2) { + [$user, $password] = $credentials; + $headerBag->set('PHP_AUTH_USER', $user); + $headerBag->set('PHP_AUTH_PW', $password); + } + } elseif ($authorization !== null + && empty($server['PHP_AUTH_DIGEST']) + && stripos($authorization, 'digest ') === 0 + ) { + $headerBag->set('PHP_AUTH_DIGEST', $authorization); + $server['PHP_AUTH_DIGEST'] = $authorization; + } elseif ($authorization !== null && stripos($authorization, 'bearer ') === 0) { + $headerBag->set('AUTHORIZATION', $authorization); + } + } + + if ($headerBag->has('AUTHORIZATION')) { + return $headerBag; + } + + if ($headerBag->has('PHP_AUTH_USER')) { + $headerBag->set('AUTHORIZATION', 'Basic ' . base64_encode( + $headerBag->get('PHP_AUTH_USER') . ':' . $headerBag->get('PHP_AUTH_PW') + )); + } elseif ($headerBag->has('PHP_AUTH_DIGEST')) { + $headerBag->set('AUTHORIZATION', $headerBag->get('PHP_AUTH_DIGEST')); + } + + return $headerBag; + } + /** * Transform Swoole's server params to $_SERVER style. * @@ -177,4 +235,32 @@ protected static function normalizeTrailingSlash(array $server): array return $server; } + + /** + * Extract the raw path from the normalized Swoole request URI. + */ + protected static function extractPathInfo(array $server): ?string + { + foreach (['SCRIPT_FILENAME', 'SCRIPT_NAME', 'PHP_SELF', 'ORIG_SCRIPT_NAME', 'UNENCODED_URL', 'ORIG_PATH_INFO'] as $name) { + if (! empty($server[$name])) { + return null; + } + } + + $requestUri = $server['REQUEST_URI'] ?? null; + + if (! is_string($requestUri)) { + return null; + } + + if (($queryPosition = strpos($requestUri, '?')) !== false) { + $requestUri = substr($requestUri, 0, $queryPosition); + } + + if ($requestUri === '') { + return '/'; + } + + return $requestUri[0] === '/' ? $requestUri : '/' . $requestUri; + } } diff --git a/src/http-server/src/RequestHeaderBag.php b/src/http-server/src/RequestHeaderBag.php new file mode 100644 index 000000000..86b9d549a --- /dev/null +++ b/src/http-server/src/RequestHeaderBag.php @@ -0,0 +1,31 @@ + $values) { + $key = strtr((string) $key, self::UPPER, self::LOWER); + $this->headers[$key] = is_array($values) + ? array_values($values) + : [$values]; + } + + if (isset($this->headers['cache-control'])) { + $this->cacheControl = $this->parseCacheControl( + implode(', ', $this->headers['cache-control']) + ); + } + } +} diff --git a/src/http/src/Request.php b/src/http/src/Request.php index 3bc338899..b41eabf18 100644 --- a/src/http/src/Request.php +++ b/src/http/src/Request.php @@ -17,14 +17,19 @@ use Hypervel\Support\Traits\Macroable; use Hypervel\Support\Uri; use Override; +use ReflectionProperty; use RuntimeException; use Symfony\Component\HttpFoundation\Exception\ConflictingHeadersException; use Symfony\Component\HttpFoundation\Exception\SessionNotFoundException; use Symfony\Component\HttpFoundation\Exception\SuspiciousOperationException; +use Symfony\Component\HttpFoundation\FileBag; +use Symfony\Component\HttpFoundation\HeaderBag; use Symfony\Component\HttpFoundation\HeaderUtils; use Symfony\Component\HttpFoundation\InputBag; use Symfony\Component\HttpFoundation\IpUtils; +use Symfony\Component\HttpFoundation\ParameterBag; use Symfony\Component\HttpFoundation\Request as SymfonyRequest; +use Symfony\Component\HttpFoundation\ServerBag; use Symfony\Component\HttpFoundation\Session\SessionInterface; /** @@ -145,8 +150,53 @@ class Request extends SymfonyRequest implements Arrayable, ArrayAccess */ protected bool $isForwardedValidValue = true; + /** + * Transport URI associated with a precomputed path. + */ + protected ?string $transportRequestUri = null; + + /** + * Raw accessors for Symfony's hooked request bag properties. + * + * @var array + */ + private static array $baseRequestProperties = []; + // Request::capture() is omitted because the Swoole bridge creates each request. + /** + * Create a request, optionally reusing headers and path data prepared by the transport. + */ + public function __construct( + array $query = [], + array $request = [], + array $attributes = [], + array $cookies = [], + array $files = [], + array $server = [], + mixed $content = null, + ?HeaderBag $headers = null, + ?string $pathInfo = null, + ) { + if ($headers === null) { + parent::__construct($query, $request, $attributes, $cookies, $files, $server, $content); + + return; + } + + $this->initializeWithHeaderBag( + $query, + $request, + $attributes, + $cookies, + $files, + $server, + $content, + $headers, + $pathInfo, + ); + } + /** * Initialize the request data. */ @@ -160,6 +210,8 @@ public function initialize(array $query = [], array $request = [], array $attrib parent::initialize($query, $request, $attributes, $cookies, $files, $server, $content); + $this->transportRequestUri = null; + $this->trustedProxiesValue = []; $this->trustedHeaderSetValue = -1; $this->trustedHostPatternsValue = []; @@ -167,6 +219,81 @@ public function initialize(array $query = [], array $request = [], array $attrib $this->resetTrustedRequestCaches(); } + /** + * Initialize request bags while retaining headers already normalized by the transport. + */ + protected function initializeWithHeaderBag( + array $query, + array $request, + array $attributes, + array $cookies, + array $files, + array $server, + mixed $content, + HeaderBag $headers, + ?string $pathInfo, + ): void { + $this->startedAtTimestamp = (float) ($server['REQUEST_TIME_FLOAT'] ?? microtime(true)); + + $server['REQUEST_TIME_FLOAT'] ??= $this->startedAtTimestamp; + $server['REQUEST_TIME'] ??= (int) $this->startedAtTimestamp; + + $this->setBaseRequestProperty('request', new InputBag($request)); + $this->setBaseRequestProperty('query', new InputBag($query)); + $this->setBaseRequestProperty('attributes', new ParameterBag($attributes)); + $this->setBaseRequestProperty('cookies', new InputBag($cookies)); + $this->setBaseRequestProperty('files', new FileBag($files)); + $this->setBaseRequestProperty('server', new ServerBag($server)); + $this->setBaseRequestProperty('headers', $headers); + + $this->content = $content; + $this->languages = null; + $this->charsets = null; + $this->encodings = null; + $this->acceptableContentTypes = null; + $this->pathInfo = $pathInfo; + $this->transportRequestUri = $pathInfo === null ? null : (string) $server['REQUEST_URI']; + $this->requestUri = null; + $this->baseUrl = null; + $this->basePath = null; + $this->method = null; + $this->format = null; + + $this->trustedProxiesValue = []; + $this->trustedHeaderSetValue = -1; + $this->trustedHostPatternsValue = []; + $this->trustedHostsValue = []; + $this->resetTrustedRequestCaches(); + } + + /** + * Set a Symfony Request bag without triggering its public-property deprecation hook. + */ + private function setBaseRequestProperty(string $name, object $value): void + { + $property = self::$baseRequestProperties[$name] + ??= new ReflectionProperty(SymfonyRequest::class, $name); + + $property->setRawValue($this, $value); + } + + /** + * Return the precomputed transport path unless middleware rewrote its URI. + */ + #[Override] + public function getPathInfo(): string + { + if ($this->transportRequestUri !== null) { + if ($this->server->get('REQUEST_URI') !== $this->transportRequestUri) { + $this->pathInfo = null; + } + + $this->transportRequestUri = null; + } + + return parent::getPathInfo(); + } + /** * Create a new HTTP request from PHP superglobals. * diff --git a/tests/HttpServer/RequestBridgeTest.php b/tests/HttpServer/RequestBridgeTest.php index 2418ee4e3..dcc93cba9 100644 --- a/tests/HttpServer/RequestBridgeTest.php +++ b/tests/HttpServer/RequestBridgeTest.php @@ -12,7 +12,9 @@ use Hypervel\Tests\TestCase; use Mockery as m; use PHPUnit\Framework\Attributes\DataProvider; +use ReflectionProperty; use Swoole\Http\Request as SwooleRequest; +use Symfony\Component\HttpFoundation\Request as SymfonyRequest; class RequestBridgeTest extends TestCase { @@ -45,6 +47,8 @@ public function testCreateFromSwooleWithGetRequest(): void $request = RequestBridge::createFromSwoole($swooleRequest); $this->assertInstanceOf(Request::class, $request); + $this->assertSame('/users', (new ReflectionProperty(SymfonyRequest::class, 'pathInfo'))->getValue($request)); + $this->assertNull((new ReflectionProperty(SymfonyRequest::class, 'method'))->getValue($request)); $this->assertSame('GET', $request->getMethod()); $this->assertSame('/users', $request->getPathInfo()); $this->assertSame('1', $request->query->get('page')); @@ -61,11 +65,40 @@ public function testCreateFromSwooleWithPostRequest(): void $request = RequestBridge::createFromSwoole($swooleRequest); + $this->assertNull((new ReflectionProperty(SymfonyRequest::class, 'method'))->getValue($request)); $this->assertSame('POST', $request->getMethod()); $this->assertSame('Taylor', $request->request->get('name')); $this->assertSame('taylor@example.com', $request->request->get('email')); } + public function testPathInfoFallsBackWhenMiddlewareRewritesRequestUri(): void + { + $request = RequestBridge::createFromSwoole($this->createSwooleRequest( + server: ['request_method' => 'get', 'request_uri' => '/original'], + header: ['host' => 'example.com'], + )); + + $request->server->set('REQUEST_URI', '/rewritten'); + + $this->assertSame('/rewritten', $request->getPathInfo()); + } + + public function testPathInfoFallsBackForFrontControllerServerParams(): void + { + $request = RequestBridge::createFromSwoole($this->createSwooleRequest( + server: [ + 'request_method' => 'get', + 'request_uri' => '/index.php/users', + 'script_name' => '/index.php', + 'script_filename' => '/var/www/index.php', + ], + header: ['host' => 'example.com'], + )); + + $this->assertNull((new ReflectionProperty(SymfonyRequest::class, 'pathInfo'))->getValue($request)); + $this->assertSame('/users', $request->getPathInfo()); + } + public function testCreateFromSwooleWithCookies(): void { $swooleRequest = $this->createSwooleRequest( @@ -151,6 +184,74 @@ public function testHeadersGetHttpPrefix(): void $this->assertSame('Bearer token123', $request->headers->get('authorization')); } + #[DataProvider('authorizationHeaderProvider')] + public function testAuthorizationHeadersMatchSymfonyNormalization(string $authorization): void + { + $swooleRequest = $this->createSwooleRequest( + server: ['request_method' => 'get', 'request_uri' => '/'], + header: ['host' => 'example.com', 'authorization' => $authorization], + ); + + $request = RequestBridge::createFromSwoole($swooleRequest); + $expected = new Request(server: [ + 'HTTP_HOST' => 'example.com', + 'HTTP_AUTHORIZATION' => $authorization, + ]); + + $this->assertSame($expected->headers->all(), $request->headers->all()); + } + + public static function authorizationHeaderProvider(): iterable + { + yield 'basic' => ['Basic ' . base64_encode('user:password')]; + yield 'digest' => ['Digest username="user"']; + yield 'bearer' => ['Bearer token']; + } + + public function testServerBasicAuthorizationMatchesSymfonyNormalization(): void + { + $swooleRequest = $this->createSwooleRequest( + server: [ + 'request_method' => 'get', + 'request_uri' => '/', + 'php_auth_user' => 'server-user', + 'php_auth_pw' => 'server-password', + ], + header: ['host' => 'example.com'], + ); + + $request = RequestBridge::createFromSwoole($swooleRequest); + $expected = new Request(server: [ + 'HTTP_HOST' => 'example.com', + 'PHP_AUTH_USER' => 'server-user', + 'PHP_AUTH_PW' => 'server-password', + ]); + + $this->assertSame($expected->headers->all(), $request->headers->all()); + } + + public function testRedirectDigestAuthorizationMatchesSymfonyNormalization(): void + { + $authorization = 'Digest username="redirect-user"'; + $swooleRequest = $this->createSwooleRequest( + server: [ + 'request_method' => 'get', + 'request_uri' => '/', + 'redirect_http_authorization' => $authorization, + ], + header: ['host' => 'example.com'], + ); + + $request = RequestBridge::createFromSwoole($swooleRequest); + $expected = new Request(server: [ + 'HTTP_HOST' => 'example.com', + 'REDIRECT_HTTP_AUTHORIZATION' => $authorization, + ]); + + $this->assertSame($expected->headers->all(), $request->headers->all()); + $this->assertSame($authorization, $request->server->get('PHP_AUTH_DIGEST')); + } + public function testMixedCaseAndUnknownHeadersUseGenericNormalization(): void { $swooleRequest = $this->createSwooleRequest( @@ -170,7 +271,11 @@ public function testMixedCaseAndUnknownHeadersUseGenericNormalization(): void public function testContentTypeAndContentLengthGetSpecialTreatment(): void { $swooleRequest = $this->createSwooleRequest( - server: ['request_method' => 'post', 'request_uri' => '/'], + server: [ + 'request_method' => 'post', + 'request_uri' => '/', + 'content_md5' => 'checksum', + ], header: [ 'host' => 'example.com', 'content-type' => 'application/json', @@ -187,6 +292,23 @@ public function testContentTypeAndContentLengthGetSpecialTreatment(): void // They should also be accessible via headers (HttpFoundation normalizes from server) $this->assertSame('application/json', $request->headers->get('content-type')); + $this->assertSame('checksum', $request->headers->get('content-md5')); + } + + public function testCacheControlHeaderRetainsParsedDirectives(): void + { + $swooleRequest = $this->createSwooleRequest( + server: ['request_method' => 'get', 'request_uri' => '/'], + header: [ + 'host' => 'example.com', + 'cache-control' => 'no-cache, max-age=60', + ], + ); + + $request = RequestBridge::createFromSwoole($swooleRequest); + + $this->assertTrue($request->headers->hasCacheControlDirective('no-cache')); + $this->assertSame('60', $request->headers->getCacheControlDirective('max-age')); } #[DataProvider('requestUriProvider')] From cc3e92e77529dd5ba0cb294bbf626a9581a49489 Mon Sep 17 00:00:00 2001 From: Albert Chen Date: Tue, 18 Aug 2026 09:34:40 +0800 Subject: [PATCH 25/34] perf(routing): streamline compiled matching and parameter binding Compiled matching built a complete RequestContext and consulted the empty dynamic collection on every miss, method mismatch, and fallback. Clone a small context template, populate scheme, port, query, and condition data only when the compiled table needs them, and return directly when no dynamic routes exist. Matcher parameters are copied in URI order without temporary flip/intersection arrays, and current/original route parameters are stored together. Scheme and port constraints, conditions, OPTIONS/405 responses, fallbacks, dynamic routes, and middleware mutation retain explicit covered paths. --- src/routing/src/CompiledRouteCollection.php | 142 +++++++++++++++--- src/routing/src/Route.php | 10 +- src/routing/src/RouteParameterBinder.php | 14 +- src/routing/src/Router.php | 4 + .../Routing/CompiledRouteCollectionTest.php | 103 ++++++++++++- 5 files changed, 244 insertions(+), 29 deletions(-) diff --git a/src/routing/src/CompiledRouteCollection.php b/src/routing/src/CompiledRouteCollection.php index 2034489dd..8338a98db 100644 --- a/src/routing/src/CompiledRouteCollection.php +++ b/src/routing/src/CompiledRouteCollection.php @@ -32,6 +32,11 @@ class CompiledRouteCollection extends AbstractRouteCollection */ protected ?RouteCollection $routes = null; + /** + * Whether routes have been added after this collection was compiled. + */ + protected bool $hasDynamicRoutes = false; + /** * The router instance used by the route. */ @@ -49,6 +54,26 @@ class CompiledRouteCollection extends AbstractRouteCollection */ protected array $nameCache = []; + /** + * An immutable request context template cloned for each match. + */ + protected RequestContext $requestContextPrototype; + + /** + * Whether any compiled route has a scheme constraint. + */ + protected bool $requiresScheme; + + /** + * Whether compiled route conditions require the complete request context. + */ + protected bool $requiresFullRequestContext; + + /** + * Whether any compiled route has a port constraint. + */ + protected bool $hasPortConstraints; + /** * A cache of route names grouped by the HTTP method they respond to, built from the route attributes. * @@ -81,6 +106,42 @@ public function __construct(array $compiled, array $attributes) $this->compiled = $compiled; $this->attributes = $attributes; $this->routes = new RouteCollection; + $this->requestContextPrototype = new RequestContext; + $this->requiresScheme = $this->compiledRoutesRequireScheme($compiled); + $this->requiresFullRequestContext = ($compiled[4] ?? null) !== null; + $this->hasPortConstraints = $this->compiledRoutesHavePortConstraints($attributes); + } + + /** + * Determine whether the compiled matcher contains scheme-constrained routes. + */ + protected function compiledRoutesRequireScheme(array $compiled): bool + { + foreach ([$compiled[1] ?? [], $compiled[3] ?? []] as $routeGroups) { + foreach ($routeGroups as $routes) { + foreach ($routes as $route) { + if (! empty($route[3])) { + return true; + } + } + } + } + + return false; + } + + /** + * Determine whether any compiled route requires a specific server port. + */ + protected function compiledRoutesHavePortConstraints(array $attributes): bool + { + foreach ($attributes as $route) { + if (($route['action']['port'] ?? null) !== null) { + return true; + } + } + + return false; } /** @@ -92,7 +153,10 @@ public function add(Route $route): Route { $this->ensureNoCrossPortConflictWithCompiledRoutes($route); - return $this->routes->add($route); + $route = $this->routes->add($route); + $this->hasDynamicRoutes = true; + + return $route; } /** @@ -161,9 +225,10 @@ public function refreshActionLookups(): void /** * Find the first route matching a given request. * - * Fresh RequestContext per request for coroutine safety — a shared mutable - * RequestContext would race under coroutine interleaving. The allocation - * cost of one small object per request is negligible. + * Clone an immutable RequestContext template per request for coroutine safety. + * A shared mutable context would race under coroutine interleaving. Ordinary + * routes populate only method and validated host; scheme and complete request + * metadata are resolved only when the compiled route table requires them. * * No request duplication needed — trailing slashes are trimmed via rtrim() * on the path string and passed to match() directly, avoiding the overhead @@ -175,18 +240,36 @@ public function refreshActionLookups(): void */ public function match(Request $request): Route { - $context = new RequestContext( - method: $request->getMethod(), - host: $request->getHost(), - scheme: $request->getScheme(), - httpPort: $request->isSecure() ? 443 : (int) $request->getPort(), - httpsPort: $request->isSecure() ? (int) $request->getPort() : 443, - path: $request->getPathInfo(), - queryString: $request->server->get('QUERY_STRING', ''), - ); + $method = $request->getMethod(); + $host = $request->getHost(); + $pathInfo = $request->getPathInfo(); + + if ($this->requiresFullRequestContext) { + $context = new RequestContext( + method: $method, + host: $host, + scheme: $request->getScheme(), + httpPort: $request->isSecure() ? 443 : (int) $request->getPort(), + httpsPort: $request->isSecure() ? (int) $request->getPort() : 443, + path: $pathInfo, + queryString: $request->server->get('QUERY_STRING', ''), + ); + } else { + $context = clone $this->requestContextPrototype; + + if ($method !== 'GET') { + $context->setMethod($method); + } + + $context->setHost($host); + + if ($this->requiresScheme) { + $context->setScheme($request->getScheme()); + } + } $matcher = new CompiledUrlMatcher($this->compiled, $context); - $path = rtrim($request->getPathInfo(), '/') ?: '/'; + $path = rtrim($pathInfo, '/') ?: '/'; $route = null; $result = null; @@ -195,7 +278,23 @@ public function match(Request $request): Route if ($result = $matcher->match($path)) { $route = $this->getByName($result['_route']); } - } catch (ResourceNotFoundException|MethodNotAllowedException) { + } catch (MethodNotAllowedException $exception) { + if (! $this->hasDynamicRoutes && ! $this->hasPortConstraints) { + return $this->getRouteForMethods($request, $exception->getAllowedMethods()); + } + + try { + return $this->routes->match($request); + } catch (NotFoundHttpException) { + } + } catch (ResourceNotFoundException) { + if (! $this->hasDynamicRoutes) { + throw new NotFoundHttpException(sprintf( + 'The route %s could not be found.', + $request->path() + )); + } + try { return $this->routes->match($request); } catch (NotFoundHttpException) { @@ -207,14 +306,17 @@ public function match(Request $request): Route $routePort = $route?->getPort(); if ($routePort !== null && $routePort !== (int) $request->getPort()) { - try { - return $this->routes->match($request); - } catch (NotFoundHttpException|MethodNotAllowedHttpException) { - $route = null; + $route = null; + + if ($this->hasDynamicRoutes) { + try { + return $this->routes->match($request); + } catch (NotFoundHttpException|MethodNotAllowedHttpException) { + } } } - if ($route && $route->isFallback) { + if ($route && $route->isFallback && $this->hasDynamicRoutes) { try { $dynamicRoute = $this->routes->match($request); diff --git a/src/routing/src/Route.php b/src/routing/src/Route.php index 344469a37..af1ae09d8 100755 --- a/src/routing/src/Route.php +++ b/src/routing/src/Route.php @@ -518,8 +518,12 @@ public function bindFromCompiledMatch(array $parameters, Request $request): stat */ private function storeParameters(array $parameters): static { - CoroutineContext::set($this->parametersContextKey(), $parameters); - CoroutineContext::set($this->originalParametersContextKey(), $parameters); + $routeId = spl_object_id($this); + + CoroutineContext::setMany([ + self::PARAMS_CONTEXT_KEY_PREFIX . $routeId => $parameters, + self::ORIGINAL_PARAMS_CONTEXT_KEY_PREFIX . $routeId => $parameters, + ]); return $this; } @@ -1491,7 +1495,7 @@ public function toSymfonyRoute(): SymfonyRoute $this->wheres, ['utf8' => true], $this->getDomain() ?: '', - [], + $this->httpOnly() ? ['http'] : ($this->httpsOnly() ? ['https'] : []), $this->methods ); } diff --git a/src/routing/src/RouteParameterBinder.php b/src/routing/src/RouteParameterBinder.php index b23768aee..5b2e537c8 100644 --- a/src/routing/src/RouteParameterBinder.php +++ b/src/routing/src/RouteParameterBinder.php @@ -82,11 +82,17 @@ protected function matchToKeys(array $matches): array return []; } - $parameters = array_intersect_key($matches, array_flip($parameterNames)); + $parameters = []; - return array_filter($parameters, function ($value) { - return is_string($value) && strlen($value) > 0; - }); + foreach ($parameterNames as $parameterName) { + $value = $matches[$parameterName] ?? null; + + if (is_string($value) && $value !== '') { + $parameters[$parameterName] = $value; + } + } + + return $parameters; } /** diff --git a/src/routing/src/Router.php b/src/routing/src/Router.php index 3e5fc6861..28958118a 100644 --- a/src/routing/src/Router.php +++ b/src/routing/src/Router.php @@ -711,6 +711,10 @@ protected function newPipeline(): BasePipeline */ protected function middlewareFor(Route $route): array { + if ($route->resolvedMiddleware === []) { + return []; + } + $disabled = $this->container->bound('middleware.disable') && $this->container->make('middleware.disable') === true; diff --git a/tests/Integration/Routing/CompiledRouteCollectionTest.php b/tests/Integration/Routing/CompiledRouteCollectionTest.php index 8983bd833..7f9ef8fd9 100644 --- a/tests/Integration/Routing/CompiledRouteCollectionTest.php +++ b/tests/Integration/Routing/CompiledRouteCollectionTest.php @@ -13,6 +13,7 @@ use Hypervel\Support\Arr; use Symfony\Component\HttpKernel\Exception\MethodNotAllowedHttpException; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; +use Symfony\Component\Routing\RequestContext as SymfonyRequestContext; class CompiledRouteCollectionTest extends RoutingTestCase { @@ -300,9 +301,21 @@ public function testMatchingThrowsMethodNotAllowedHttpExceptionWhenMethodIsNotAl { $this->routeCollection->add($this->newRoute('GET', '/foo', ['uses' => 'FooController@index'])); - $this->expectException(MethodNotAllowedHttpException::class); + try { + $this->collection()->match(Request::create('/foo', 'POST')); + $this->fail('Expected the compiled route to reject the POST method.'); + } catch (MethodNotAllowedHttpException $exception) { + $this->assertSame('GET, HEAD', $exception->getHeaders()['Allow']); + } + } - $this->collection()->match(Request::create('/foo', 'POST')); + public function testOptionsRequestUsesCompiledAllowedMethods(): void + { + $this->routeCollection->add($this->newRoute('GET', '/foo', ['uses' => 'FooController@index'])); + + $route = $this->collection()->match(Request::create('/foo', 'OPTIONS')); + + $this->assertSame(['OPTIONS'], $route->methods()); } public function testMatchingThrowsExceptionWhenMethodIsNotAllowedWhileSameRouteIsAddedDynamically() @@ -415,6 +428,75 @@ public function testCompiledMatchBindsDomainAndPathParameters(): void $this->assertSame(['tenant' => 'hypervel', 'user' => '42'], $route->originalParameters()); } + public function testCompiledMatchHonorsHttpsOnlyRoute(): void + { + $this->routeCollection->add( + $this->newRoute('GET', '/secure/{resource}', [ + 'uses' => 'FooController@index', + 'as' => 'secure', + 'https', + ]) + ); + + $this->assertSame( + 'secure', + $this->collection()->match(Request::create('https://example.com/secure/report'))->getName() + ); + + $this->expectException(NotFoundHttpException::class); + + $this->collection()->match(Request::create('http://example.com/secure/report')); + } + + public function testCompiledMatchHonorsHttpOnlyRoute(): void + { + $this->routeCollection->add( + $this->newRoute('GET', '/insecure', [ + 'uses' => 'FooController@index', + 'as' => 'insecure', + 'http', + ]) + ); + + $this->assertSame( + 'insecure', + $this->collection()->match(Request::create('http://example.com/insecure'))->getName() + ); + + $this->expectException(NotFoundHttpException::class); + + $this->collection()->match(Request::create('https://example.com/insecure')); + } + + public function testCompiledConditionReceivesCompleteRequestContext(): void + { + $this->routeCollection->add( + $this->newRoute('GET', '/conditional', [ + 'uses' => 'FooController@index', + 'as' => 'conditional', + ]) + ); + + $compiled = $this->routeCollection->compile(); + $compiled['compiled'][1]['/conditional'][0][6] = -1; + $compiled['compiled'][4] = static fn ( + int $condition, + SymfonyRequestContext $context + ): bool => $condition === -1 && $context->getQueryString() === 'token=1'; + $collection = (new CompiledRouteCollection($compiled['compiled'], $compiled['attributes'])) + ->setRouter($this->router) + ->setContainer($this->app); + + $this->assertSame( + 'conditional', + $collection->match(Request::create('/conditional?token=1'))->getName() + ); + + $this->expectException(NotFoundHttpException::class); + + $collection->match(Request::create('/conditional?token=2')); + } + public function testCompiledMatchAppliesOptionalParameterDefaults(): void { $this->routeCollection->add( @@ -430,6 +512,23 @@ public function testCompiledMatchAppliesOptionalParameterDefaults(): void $this->assertSame(['period' => 'current'], $route->originalParameters()); } + public function testCompiledMatchKeepsUriParameterOrderWhenDefaultsArePresent(): void + { + $this->routeCollection->add( + $this->newRoute('GET', '/reports/{category}/{period?}', [ + 'uses' => 'FooController@index', + 'as' => 'reports.category', + ])->defaults('period', 'current') + ); + + $route = $this->collection()->match(Request::create('/reports/sales', 'GET')); + + $this->assertSame( + ['category' => 'sales', 'period' => 'current'], + $route->parameters() + ); + } + public function testCompiledMatchAppliesDefaultsWithoutUriParameters(): void { $this->routeCollection->add( From b57a5fbf6ba1ae54b440a63c4aa0b992b3a2025f Mon Sep 17 00:00:00 2001 From: Albert Chen Date: Tue, 18 Aug 2026 09:35:10 +0800 Subject: [PATCH 26/34] perf(http): optimize response construction, preparation, and headers Response construction still formatted Date per instance, and Symfony header lookups materialized unrelated values while prepare() repeated a broad set of checks for ordinary responses. Refresh the shared prototype Date once per second, use a response bag with direct keyed access and Swoole emission, and inline the side-effect-free common preparation path. HEAD, HTTP/1.0, informational, 204/304, request-format, secure-cookie, and legacy IE download cases fall back to Symfony. Response construction drops from 1.33us to 0.85us, repeated prepare from 1.78us to 0.62us, and Router string conversion from 4.21us to 2.36us. --- src/http/src/Concerns/PreparesResponse.php | 58 ++++++++++++++ src/http/src/JsonResponse.php | 23 ++++-- src/http/src/Response.php | 22 ++++-- src/http/src/ResponseHeaderBag.php | 62 +++++++++++++++ .../ResponsePerformanceOptimizationTest.php | 77 +++++++++++++++++++ 5 files changed, 231 insertions(+), 11 deletions(-) create mode 100644 src/http/src/Concerns/PreparesResponse.php create mode 100644 src/http/src/ResponseHeaderBag.php create mode 100644 tests/Http/ResponsePerformanceOptimizationTest.php diff --git a/src/http/src/Concerns/PreparesResponse.php b/src/http/src/Concerns/PreparesResponse.php new file mode 100644 index 000000000..895e606f8 --- /dev/null +++ b/src/http/src/Concerns/PreparesResponse.php @@ -0,0 +1,58 @@ +statusCode < 200 + || $this->statusCode === 204 + || $this->statusCode === 304 + || $request->getMethod() === 'HEAD' + || $request->server->get('SERVER_PROTOCOL') === 'HTTP/1.0' + ) { + return parent::prepare($request); + } + + $contentType = $this->headers->get('Content-Type'); + + // A missing content type requires Symfony's request-format lookup. + if ($contentType === null) { + return parent::prepare($request); + } + + // HTTPS preparation mutates cookie defaults and contains a legacy IE + // download workaround. Fall back only when either can be relevant. + if ($request->isSecure() + && ($this->headers->getCookies() !== [] + || stripos($this->headers->get('Content-Disposition') ?? '', 'attachment') !== false) + ) { + return parent::prepare($request); + } + + if (stripos($contentType, 'text/') === 0 && stripos($contentType, 'charset') === false) { + $this->headers->set('Content-Type', $contentType . '; charset=' . ($this->charset ?: 'utf-8')); + } + + if ($this->headers->has('Transfer-Encoding')) { + $this->headers->remove('Content-Length'); + } + + $this->version = '1.1'; + + return $this; + } +} diff --git a/src/http/src/JsonResponse.php b/src/http/src/JsonResponse.php index 64740aa62..c3176f806 100755 --- a/src/http/src/JsonResponse.php +++ b/src/http/src/JsonResponse.php @@ -15,11 +15,11 @@ use Stringable; use Symfony\Component\HttpFoundation\JsonResponse as BaseJsonResponse; use Symfony\Component\HttpFoundation\Response as SymfonyResponse; -use Symfony\Component\HttpFoundation\ResponseHeaderBag; use TypeError; class JsonResponse extends BaseJsonResponse { + use Concerns\PreparesResponse; use ResponseTrait, Macroable { Macroable::__call as macroCall; } @@ -29,6 +29,11 @@ class JsonResponse extends BaseJsonResponse */ protected static ?ResponseHeaderBag $headerPrototype = null; + /** + * Unix second represented by the prototype's Date header. + */ + protected static int $headerPrototypeTimestamp = 0; + /** * Create a new JSON response instance. */ @@ -55,12 +60,17 @@ public function __construct(mixed $data = null, int $status = 200, array $header // skips it. SymfonyResponse::__construct is called rather than // parent::__construct because JsonResponse's signature only accepts an // array of headers, while Response's also accepts a prepared bag. - $bag = clone (static::$headerPrototype ??= new ResponseHeaderBag); + $timestamp = time(); + + if (static::$headerPrototype === null) { + static::$headerPrototype = new ResponseHeaderBag; + static::$headerPrototypeTimestamp = $timestamp; + } elseif (static::$headerPrototypeTimestamp !== $timestamp) { + static::$headerPrototype->set('Date', gmdate('D, d M Y H:i:s', $timestamp) . ' GMT'); + static::$headerPrototypeTimestamp = $timestamp; + } - // The bag stamps Date when it is constructed, so a clone carries the - // prototype's timestamp and has to be given the current one. Given - // headers are added after, so a caller supplying Date still wins. - $bag->set('Date', gmdate('D, d M Y H:i:s') . ' GMT'); + $bag = clone static::$headerPrototype; if ($headers !== []) { $bag->add($headers); @@ -169,5 +179,6 @@ public static function flushState(): void static::flushMacros(); static::$headerPrototype = null; + static::$headerPrototypeTimestamp = 0; } } diff --git a/src/http/src/Response.php b/src/http/src/Response.php index 5515e3c64..c6c6d49f2 100755 --- a/src/http/src/Response.php +++ b/src/http/src/Response.php @@ -15,13 +15,13 @@ use RuntimeException; use Stringable; use Symfony\Component\HttpFoundation\Response as SymfonyResponse; -use Symfony\Component\HttpFoundation\ResponseHeaderBag; class Response extends SymfonyResponse { use Macroable { Macroable::__call as macroCall; } + use Concerns\PreparesResponse; use ResponseTrait; /** @@ -29,6 +29,11 @@ class Response extends SymfonyResponse */ protected static ?ResponseHeaderBag $headerPrototype = null; + /** + * Unix second represented by the prototype's Date header. + */ + protected static int $headerPrototypeTimestamp = 0; + /** * Create a new HTTP response. * @@ -36,11 +41,17 @@ class Response extends SymfonyResponse */ public function __construct(mixed $content = '', int $status = 200, array $headers = []) { - $bag = clone (static::$headerPrototype ??= new ResponseHeaderBag); + $timestamp = time(); + + if (static::$headerPrototype === null) { + static::$headerPrototype = new ResponseHeaderBag; + static::$headerPrototypeTimestamp = $timestamp; + } elseif (static::$headerPrototypeTimestamp !== $timestamp) { + static::$headerPrototype->set('Date', gmdate('D, d M Y H:i:s', $timestamp) . ' GMT'); + static::$headerPrototypeTimestamp = $timestamp; + } - // A cloned bag carries the prototype's timestamp. Refresh it before - // adding the given headers so a caller-supplied Date still wins. - $bag->set('Date', gmdate('D, d M Y H:i:s') . ' GMT'); + $bag = clone static::$headerPrototype; if ($headers !== []) { $bag->add($headers); @@ -171,5 +182,6 @@ public static function flushState(): void static::flushMacros(); static::$headerPrototype = null; + static::$headerPrototypeTimestamp = 0; } } diff --git a/src/http/src/ResponseHeaderBag.php b/src/http/src/ResponseHeaderBag.php new file mode 100644 index 000000000..450d01b2c --- /dev/null +++ b/src/http/src/ResponseHeaderBag.php @@ -0,0 +1,62 @@ +getCookies()) + : $this->headers[$key] ?? []; + } + + /** + * Determine whether a response header is present. + */ + #[Override] + public function has(string $key): bool + { + $key = strtr($key, self::UPPER, self::LOWER); + + return $key === 'set-cookie' + ? $this->cookies !== [] + : array_key_exists($key, $this->headers); + } + + /** + * Return headers with their original capitalization, excluding cookies. + */ + #[Override] + public function allPreserveCaseWithoutCookies(): array + { + $headers = []; + + // Symfony's implementation first materializes Set-Cookie strings and + // then removes them. Swoole sends cookies separately, so iterate the + // already cookie-free header storage directly. + foreach ($this->headers as $name => $values) { + $headers[$this->headerNames[$name] ?? $name] = $values; + } + + return $headers; + } +} diff --git a/tests/Http/ResponsePerformanceOptimizationTest.php b/tests/Http/ResponsePerformanceOptimizationTest.php new file mode 100644 index 000000000..eb4c01bd3 --- /dev/null +++ b/tests/Http/ResponsePerformanceOptimizationTest.php @@ -0,0 +1,77 @@ + 'HTTP/1.1']); + $expected = new SymfonyResponse('content', 200, $headers); + $actual = new Response('content', 200, $headers); + + $expected->prepare($request); + $actual->prepare($request); + + $expectedHeaders = $expected->headers->all(); + $actualHeaders = $actual->headers->all(); + ksort($expectedHeaders); + ksort($actualHeaders); + + $this->assertSame($expected->getContent(), $actual->getContent()); + $this->assertSame($expected->getProtocolVersion(), $actual->getProtocolVersion()); + $this->assertSame($expectedHeaders, $actualHeaders); + } + + public static function commonResponsePreparationProvider(): array + { + return [ + 'text content type' => [['Content-Type' => 'text/plain']], + 'text content type with charset' => [['Content-Type' => 'text/html; charset=UTF-16']], + 'non-text content type' => [['Content-Type' => 'application/json']], + 'transfer encoding removes length' => [[ + 'Content-Type' => 'text/plain', + 'Content-Length' => '7', + 'Transfer-Encoding' => 'chunked', + ]], + 'content disposition' => [[ + 'Content-Type' => 'application/octet-stream', + 'Content-Disposition' => 'attachment; filename="file.txt"', + ]], + ]; + } + + public function testPrototypeDateIsRefreshedAndAnExplicitDateStillWins(): void + { + Response::flushState(); + new Response; + + $prototype = (new ReflectionProperty(Response::class, 'headerPrototype'))->getValue(); + $this->assertInstanceOf(ResponseHeaderBag::class, $prototype); + $prototype->set('Date', 'Thu, 01 Jan 1970 00:00:00 GMT'); + (new ReflectionProperty(Response::class, 'headerPrototypeTimestamp'))->setValue(null, 0); + + $response = new Response; + $date = $response->headers->get('Date'); + $this->assertNotNull($date); + $timestamp = strtotime($date); + $this->assertNotFalse($timestamp); + + $this->assertLessThanOrEqual(1, abs(time() - $timestamp)); + $this->assertSame( + 'Thu, 01 Jan 1970 00:00:00 GMT', + (new Response(headers: ['Date' => 'Thu, 01 Jan 1970 00:00:00 GMT']))->headers->get('Date'), + ); + } +} From 4af693d550a81381f71475cc6673dcdcd951de01 Mon Sep 17 00:00:00 2001 From: Albert Chen Date: Tue, 18 Aug 2026 09:55:29 +0800 Subject: [PATCH 27/34] docs(perf): explain request lifecycle fast paths Document the invariants behind header normalization, response prototypes, compiled routing, middleware descriptor caching, and Unicode trimming. --- src/http-server/src/RequestBridge.php | 4 ++++ src/http-server/src/RequestHeaderBag.php | 3 ++- src/http/src/Concerns/PreparesResponse.php | 5 ++++- src/http/src/JsonResponse.php | 2 ++ src/http/src/Response.php | 2 ++ src/routing/src/CompiledRouteCollection.php | 3 +++ src/routing/src/Route.php | 2 ++ src/routing/src/RouteParameterBinder.php | 2 ++ src/routing/src/Router.php | 2 ++ src/support/src/Str.php | 2 ++ 10 files changed, 25 insertions(+), 2 deletions(-) diff --git a/src/http-server/src/RequestBridge.php b/src/http-server/src/RequestBridge.php index d65521e27..e6222dd33 100644 --- a/src/http-server/src/RequestBridge.php +++ b/src/http-server/src/RequestBridge.php @@ -120,6 +120,8 @@ protected static function transformServerParams(array $server, array $headers): // Swoole headers → HTTP_* format foreach ($headers as $key => $value) { + // Swoole normally supplies lowercase names. Map common headers + // without replace/uppercase allocations; retain the generic path. $httpKey = match ($key) { 'accept' => 'HTTP_ACCEPT', 'accept-encoding' => 'HTTP_ACCEPT_ENCODING', @@ -241,6 +243,8 @@ protected static function normalizeTrailingSlash(array $server): array */ protected static function extractPathInfo(array $server): ?string { + // Front-controller and IIS metadata affect Symfony's base-path rules; + // defer to its full path derivation whenever those inputs are present. foreach (['SCRIPT_FILENAME', 'SCRIPT_NAME', 'PHP_SELF', 'ORIG_SCRIPT_NAME', 'UNENCODED_URL', 'ORIG_PATH_INFO'] as $name) { if (! empty($server[$name])) { return null; diff --git a/src/http-server/src/RequestHeaderBag.php b/src/http-server/src/RequestHeaderBag.php index 86b9d549a..6e74512d3 100644 --- a/src/http-server/src/RequestHeaderBag.php +++ b/src/http-server/src/RequestHeaderBag.php @@ -7,7 +7,8 @@ use Symfony\Component\HttpFoundation\HeaderBag; /** - * Header bag optimized for the already-separated headers supplied by Swoole. + * Store Swoole's already-separated headers directly, avoiding HeaderBag::set() + * normalization for every header while still parsing Cache-Control once. * * @internal */ diff --git a/src/http/src/Concerns/PreparesResponse.php b/src/http/src/Concerns/PreparesResponse.php index 895e606f8..f42b86b49 100644 --- a/src/http/src/Concerns/PreparesResponse.php +++ b/src/http/src/Concerns/PreparesResponse.php @@ -8,7 +8,10 @@ use Symfony\Component\HttpFoundation\Request; /** - * Optimize preparation of the common Swoole HTTP response. + * Inline Symfony's side-effect-free common response preparation path. + * + * Cases that can mutate the body, cookies, or protocol-specific headers remain + * delegated to Symfony so the fast path does not narrow response behavior. */ trait PreparesResponse { diff --git a/src/http/src/JsonResponse.php b/src/http/src/JsonResponse.php index c3176f806..bba3eb18a 100755 --- a/src/http/src/JsonResponse.php +++ b/src/http/src/JsonResponse.php @@ -60,6 +60,8 @@ public function __construct(mixed $data = null, int $status = 200, array $header // skips it. SymfonyResponse::__construct is called rather than // parent::__construct because JsonResponse's signature only accepts an // array of headers, while Response's also accepts a prepared bag. + // HTTP dates have one-second precision, so all responses created in the + // same second can clone one value. Caller headers are applied afterward. $timestamp = time(); if (static::$headerPrototype === null) { diff --git a/src/http/src/Response.php b/src/http/src/Response.php index c6c6d49f2..822bf72e8 100755 --- a/src/http/src/Response.php +++ b/src/http/src/Response.php @@ -41,6 +41,8 @@ class Response extends SymfonyResponse */ public function __construct(mixed $content = '', int $status = 200, array $headers = []) { + // HTTP dates have one-second precision, so all responses created in the + // same second can clone one value. Caller headers are applied afterward. $timestamp = time(); if (static::$headerPrototype === null) { diff --git a/src/routing/src/CompiledRouteCollection.php b/src/routing/src/CompiledRouteCollection.php index 8338a98db..8c96a0cf0 100644 --- a/src/routing/src/CompiledRouteCollection.php +++ b/src/routing/src/CompiledRouteCollection.php @@ -107,6 +107,9 @@ public function __construct(array $compiled, array $attributes) $this->attributes = $attributes; $this->routes = new RouteCollection; $this->requestContextPrototype = new RequestContext; + + // Symfony stores static/dynamic routes at top-level indexes 1/3, + // conditions at 4, and required schemes at index 3 of each route tuple. $this->requiresScheme = $this->compiledRoutesRequireScheme($compiled); $this->requiresFullRequestContext = ($compiled[4] ?? null) !== null; $this->hasPortConstraints = $this->compiledRoutesHavePortConstraints($attributes); diff --git a/src/routing/src/Route.php b/src/routing/src/Route.php index af1ae09d8..6d167ce2e 100755 --- a/src/routing/src/Route.php +++ b/src/routing/src/Route.php @@ -502,6 +502,8 @@ public function bind(Request $request): static */ public function bindFromCompiledMatch(array $parameters, Request $request): static { + // A custom Route may override bind() semantics, so only the base class + // can safely reuse parameters produced by Symfony's compiled matcher. if ($this::class !== self::class) { return $this->bind($request); } diff --git a/src/routing/src/RouteParameterBinder.php b/src/routing/src/RouteParameterBinder.php index 5b2e537c8..357914e3c 100644 --- a/src/routing/src/RouteParameterBinder.php +++ b/src/routing/src/RouteParameterBinder.php @@ -84,6 +84,8 @@ protected function matchToKeys(array $matches): array $parameters = []; + // Preserve URI order without allocating array_flip() and + // array_intersect_key() intermediates for every route match. foreach ($parameterNames as $parameterName) { $value = $matches[$parameterName] ?? null; diff --git a/src/routing/src/Router.php b/src/routing/src/Router.php index 28958118a..aa960438a 100644 --- a/src/routing/src/Router.php +++ b/src/routing/src/Router.php @@ -728,6 +728,8 @@ protected function middlewareFor(Route $route): array return []; } + // Cache only the route's canonical list. Router overrides may return + // request-dependent middleware that must be described per dispatch. if ($middleware !== $route->resolvedMiddleware) { return array_map( static fn (mixed $pipe): mixed => is_string($pipe) && ! is_callable($pipe) diff --git a/src/support/src/Str.php b/src/support/src/Str.php index 31043e655..17bc85186 100644 --- a/src/support/src/Str.php +++ b/src/support/src/Str.php @@ -1335,6 +1335,8 @@ public static function trim(string $value, ?string $charlist = null): string return ''; } + // Native trim() already consumed its ASCII charlist. Only a multibyte + // boundary or form feed can still match the Unicode whitespace set. if (ord($trimmed[0]) < 0x80 && ord($trimmed[-1]) < 0x80 && $trimmed[0] !== "\f" From c59d8ff19fe0a90bd486d1f22ce1d3f587e511d5 Mon Sep 17 00:00:00 2001 From: Albert Chen Date: Tue, 18 Aug 2026 10:50:38 +0800 Subject: [PATCH 28/34] test(http): declare transform request test return types --- tests/Foundation/Http/Middleware/TransformsRequestTest.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/Foundation/Http/Middleware/TransformsRequestTest.php b/tests/Foundation/Http/Middleware/TransformsRequestTest.php index 99f586774..482ca0e4e 100644 --- a/tests/Foundation/Http/Middleware/TransformsRequestTest.php +++ b/tests/Foundation/Http/Middleware/TransformsRequestTest.php @@ -12,7 +12,7 @@ class TransformsRequestTest extends TestCase { - public function testEmptyParameterBagIsNotReadOrReplaced() + public function testEmptyParameterBagIsNotReadOrReplaced(): void { $bag = new TrackingParameterBag; @@ -22,7 +22,7 @@ public function testEmptyParameterBagIsNotReadOrReplaced() $this->assertSame(0, $bag->replaceCalls); } - public function testNonEmptyParameterBagIsStillReadAndReplaced() + public function testNonEmptyParameterBagIsStillReadAndReplaced(): void { $bag = new TrackingParameterBag(['name' => 'Taylor']); From 6bfe22ad8f43c191399996bd9905bedeb8aa2c21 Mon Sep 17 00:00:00 2001 From: Albert Chen Date: Tue, 18 Aug 2026 13:54:57 +0800 Subject: [PATCH 29/34] perf(routing): optimize parameterless route dispatch Static compiled routes still allocated a RouteParameterBinder, route parameter reads entered context twice, and zero-argument handlers crossed array and dependency setup that could not affect the result. Skip those steps when their inputs are empty while preserving bound route state. Parameterized and defaulted routes retain the full binder, and route values are still forwarded to zero-declared-argument callables. With one worker and no middleware, a zero-argument closure rises from 20,421 to 21,037 QPS (+3.0%) and a parameterless controller from 18,683 to 19,176 QPS (+2.6%); parameterized routing remains flat. --- src/routing/src/CallableDispatcher.php | 6 ++++- src/routing/src/ResolvesRouteDependencies.php | 4 +++ src/routing/src/Route.php | 24 +++++++++++++----- .../Routing/CompiledRouteCollectionTest.php | 25 +++++++++++++++++++ tests/Routing/RoutingRouteTest.php | 15 +++++++++++ 5 files changed, 67 insertions(+), 7 deletions(-) diff --git a/src/routing/src/CallableDispatcher.php b/src/routing/src/CallableDispatcher.php index 59c99b2ae..826b13f07 100644 --- a/src/routing/src/CallableDispatcher.php +++ b/src/routing/src/CallableDispatcher.php @@ -59,7 +59,11 @@ public function dispatch(Route $route, callable $callable): mixed ->dispatch($route, $callable); } - return $callable(...array_values($this->resolveParameters($route, $callable))); + $parameters = $this->resolveParameters($route, $callable); + + return $parameters === [] + ? $callable() + : $callable(...array_values($parameters)); } /** diff --git a/src/routing/src/ResolvesRouteDependencies.php b/src/routing/src/ResolvesRouteDependencies.php index 1da6c0b03..3b1756dbd 100644 --- a/src/routing/src/ResolvesRouteDependencies.php +++ b/src/routing/src/ResolvesRouteDependencies.php @@ -57,6 +57,10 @@ protected function resolveClassMethodDependencies(array $parameters, object $ins */ public function resolveMethodDependencies(array $parameters, array $reflectedParameters): array { + if ($reflectedParameters === []) { + return $parameters; + } + $instanceCount = 0; $values = array_values($parameters); diff --git a/src/routing/src/Route.php b/src/routing/src/Route.php index 6d167ce2e..586324924 100755 --- a/src/routing/src/Route.php +++ b/src/routing/src/Route.php @@ -510,7 +510,11 @@ public function bindFromCompiledMatch(array $parameters, Request $request): stat $this->compileRoute(); - $parameters = (new RouteParameterBinder($this))->parametersFromCompiledMatch($parameters); + // Keep the bound-empty context state, but avoid allocating a binder + // when the compiled matcher cannot have produced route parameters. + $parameters = $this->parameterNames() === [] && $this->defaults === [] + ? [] + : (new RouteParameterBinder($this))->parametersFromCompiledMatch($parameters); return $this->storeParameters($parameters); } @@ -597,8 +601,10 @@ public function forgetParameter(string $name): void */ public function parameters(): array { - if (CoroutineContext::has($this->parametersContextKey())) { - return CoroutineContext::get($this->parametersContextKey()); + $parameters = CoroutineContext::get($this->parametersContextKey()); + + if ($parameters !== null) { + return $parameters; } throw new LogicException('Route is not bound.'); @@ -611,8 +617,10 @@ public function parameters(): array */ public function originalParameters(): array { - if (CoroutineContext::has($this->originalParametersContextKey())) { - return CoroutineContext::get($this->originalParametersContextKey()); + $parameters = CoroutineContext::get($this->originalParametersContextKey()); + + if ($parameters !== null) { + return $parameters; } throw new LogicException('Route is not bound.'); @@ -623,7 +631,11 @@ public function originalParameters(): array */ public function parametersWithoutNulls(): array { - return array_filter($this->parameters(), fn ($parameter) => ! is_null($parameter)); + $parameters = $this->parameters(); + + return $parameters === [] + ? [] + : array_filter($parameters, fn ($parameter) => ! is_null($parameter)); } /** diff --git a/tests/Integration/Routing/CompiledRouteCollectionTest.php b/tests/Integration/Routing/CompiledRouteCollectionTest.php index 7f9ef8fd9..e9238e8ae 100644 --- a/tests/Integration/Routing/CompiledRouteCollectionTest.php +++ b/tests/Integration/Routing/CompiledRouteCollectionTest.php @@ -392,6 +392,31 @@ public function testCompiledMatchBindsPathParametersAndOriginalValues(): void $this->assertSame(['user' => '12', 'post' => '34'], $route->originalParameters()); } + public function testCompiledMatchBindsEmptyParameterState(): void + { + $this->routeCollection->add( + $this->newRoute('GET', '/status', [ + 'uses' => 'FooController@index', + 'as' => 'status', + ]) + ); + + $route = $this->collection()->match(Request::create('/status', 'GET')); + + $this->assertTrue($route->hasParameters()); + $this->assertSame([], $route->parameters()); + $this->assertSame([], $route->originalParameters()); + + $route->setParameter('runtime', 'value'); + + $this->assertSame(['runtime' => 'value'], $route->parameters()); + $this->assertSame([], $route->originalParameters()); + + $route->forgetParameter('runtime'); + + $this->assertSame([], $route->parameters()); + } + public function testCompiledMatchUsesAnOverriddenRouteBindMethod(): void { $router = new BindTrackingRouter($this->app->make('events'), $this->app); diff --git a/tests/Routing/RoutingRouteTest.php b/tests/Routing/RoutingRouteTest.php index d0857dc30..226213bf7 100644 --- a/tests/Routing/RoutingRouteTest.php +++ b/tests/Routing/RoutingRouteTest.php @@ -504,6 +504,21 @@ public function testClassesCanBeInjectedIntoRoutes() unset($_SERVER['__test.route_inject']); } + public function testZeroArgumentCallableRetainsExtraRouteArguments(): void + { + $router = $this->getRouter(); + $arguments = null; + + $router->get('foo/{value}', function () use (&$arguments) { + $arguments = func_get_args(); + + return 'hello'; + }); + + $this->assertSame('hello', $router->dispatch(Request::create('foo/bar', 'GET'))->getContent()); + $this->assertSame(['bar'], $arguments); + } + public function testNullValuesCanBeInjectedIntoRoutes() { $container = new Container; From 6dab17e94b29670c4e907900c1406f6930bc181e Mon Sep 17 00:00:00 2001 From: Albert Chen Date: Tue, 18 Aug 2026 16:42:11 +0800 Subject: [PATCH 30/34] perf(pipeline): compile reusable pipeline closures Middleware onions rebuilt array_reverse(), array_reduce(), and every nested closure even when their pipe structure was stable. Expose toClosure() to compile that structure without binding a passable, while then() retains its transaction and finally behavior. Each invocation receives its own passable and resolves PipeDescriptor middleware from the container, so concurrent requests can share the closure without caching middleware instances. Tests cover independent values and rebinding between calls. --- src/pipeline/src/Pipeline.php | 24 +++++++++++++++++++----- tests/Pipeline/PipelineTest.php | 28 ++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 5 deletions(-) diff --git a/src/pipeline/src/Pipeline.php b/src/pipeline/src/Pipeline.php index 172dbb10b..434664be3 100644 --- a/src/pipeline/src/Pipeline.php +++ b/src/pipeline/src/Pipeline.php @@ -101,11 +101,7 @@ public function via(string $method): static */ public function then(Closure $destination): mixed { - $pipeline = array_reduce( - array_reverse($this->pipes()), - $this->carry(), - $this->prepareDestination($destination) - ); + $pipeline = $this->toClosure($destination); try { return $this->withinTransaction !== false @@ -118,6 +114,24 @@ public function then(Closure $destination): mixed } } + /** + * Compile the pipeline structure without binding it to a passable value. + * + * The returned closure receives its passable per invocation. Immutable pipe + * descriptors stay in the onion while their middleware instances are resolved + * inside each call, allowing independent requests to reuse the same structure. + * + * @internal + */ + public function toClosure(Closure $destination): Closure + { + return array_reduce( + array_reverse($this->pipes()), + $this->carry(), + $this->prepareDestination($destination) + ); + } + /** * Run the pipeline and return the result. */ diff --git a/tests/Pipeline/PipelineTest.php b/tests/Pipeline/PipelineTest.php index 0d5350846..61541acdc 100644 --- a/tests/Pipeline/PipelineTest.php +++ b/tests/Pipeline/PipelineTest.php @@ -301,6 +301,22 @@ public function testPipelineViaChangesTheMethodBeingCalledOnThePipes(): void $this->assertSame('data', $result); } + public function testCompiledClosureCanProcessIndependentValuesAndBindings(): void + { + $container = new Container; + $container->bind(PipelineTestPipeOne::class, fn () => new PipelineTestAppendPipe(':first')); + + $pipeline = (new Pipeline($container)) + ->through(PipeDescriptor::fromString(PipelineTestPipeOne::class)) + ->toClosure(fn (mixed $value): mixed => $value); + + $this->assertSame('foo:first', $pipeline('foo')); + + $container->bind(PipelineTestPipeOne::class, fn () => new PipelineTestAppendPipe(':second')); + + $this->assertSame('foo:second', $pipeline('foo')); + } + public function testPipelineViaDispatchesPerMethodForTheSamePipeClass(): void { $container = new Container; @@ -650,6 +666,18 @@ public function __invoke($piped, $next) } } +class PipelineTestAppendPipe +{ + public function __construct(private readonly string $suffix) + { + } + + public function handle(mixed $piped, Closure $next): mixed + { + return $next($piped . $this->suffix); + } +} + class PipelineTestParameterPipe { public function handle($piped, $next, $parameter1 = null, $parameter2 = null) From 7a4631b45f015aa517eed79a869157d7d85fc42b Mon Sep 17 00:00:00 2001 From: Albert Chen Date: Tue, 18 Aug 2026 16:42:27 +0800 Subject: [PATCH 31/34] perf(foundation): reuse the global middleware pipeline The singleton HTTP kernel rebuilt the same global middleware onion for every request. Compile it lazily, retain the source stack, and rebuild only when middleware configuration changes. Requests stay invocation-local and descriptor middleware still resolves from the container per call. Empty stacks keep their direct router path. In the final one-worker benchmark, nine global middleware rise from 15,612 to 16,879 QPS (+8.1%) with p50 down 6.9%. --- src/foundation/src/Http/Kernel.php | 32 ++++++++++++++++++++--- tests/Foundation/Http/KernelTest.php | 39 ++++++++++++++++++++++++++++ 2 files changed, 67 insertions(+), 4 deletions(-) diff --git a/src/foundation/src/Http/Kernel.php b/src/foundation/src/Http/Kernel.php index e22ff7797..90e830e90 100644 --- a/src/foundation/src/Http/Kernel.php +++ b/src/foundation/src/Http/Kernel.php @@ -93,6 +93,22 @@ class Kernel implements KernelContract */ protected array $terminableMiddleware = []; + /** + * The global middleware stack represented by the reusable pipeline. + * + * @var array + */ + protected array $middlewarePipelineStack = []; + + /** + * The reusable global middleware pipeline. + * + * The request is supplied to the compiled onion per invocation, and pipe + * descriptors resolve middleware inside that invocation. The closure can + * therefore be shared by concurrent requests without retaining either one. + */ + protected ?Closure $middlewarePipeline = null; + /** * Context key for the current request's start time. * @@ -185,10 +201,18 @@ protected function sendRequestThroughRouter(Request $request): Response return ($this->dispatchToRouter())($request); } - return (new Pipeline($this->app)) - ->send($request) - ->through($middleware) - ->then($this->dispatchToRouter()); + // Compile lazily on first use, then rebuild if middleware configuration + // changes so the cached onion never retains a stale pipe list. + if ($this->middlewarePipeline === null + || $this->middlewarePipelineStack !== $middleware + ) { + $this->middlewarePipelineStack = $middleware; + $this->middlewarePipeline = (new Pipeline($this->app)) + ->through($middleware) + ->toClosure($this->dispatchToRouter()); + } + + return ($this->middlewarePipeline)($request); } /** diff --git a/tests/Foundation/Http/KernelTest.php b/tests/Foundation/Http/KernelTest.php index 6abe78964..a882f7da8 100644 --- a/tests/Foundation/Http/KernelTest.php +++ b/tests/Foundation/Http/KernelTest.php @@ -4,6 +4,7 @@ namespace Hypervel\Tests\Foundation\Http; +use Closure; use Hypervel\Config\Repository; use Hypervel\Context\CoroutineContext; use Hypervel\Contracts\Debug\ExceptionHandler; @@ -501,6 +502,44 @@ public function rawRequestStartedAt(): mixed $this->assertNull($kernel->rawRequestStartedAt()); } + public function testGlobalMiddlewarePipelineIsReusedWithoutCachingMiddlewareInstances(): void + { + $app = new Application; + $events = new Dispatcher($app); + $app->instance('events', $events); + $app->bootstrapWith([]); + $resolutions = 0; + $app->bind('test-middleware', function () use (&$resolutions): object { + ++$resolutions; + + return new class { + public function handle(Request $request, Closure $next): Response + { + return $next($request); + } + }; + }); + $router = m::mock(Router::class); + $router->shouldReceive('dispatch')->twice()->andReturn(new Response); + $kernel = new class($app, $router) extends Kernel { + public function reusablePipeline(): ?Closure + { + return $this->middlewarePipeline; + } + }; + $kernel->setGlobalMiddleware(['test-middleware']); + + $kernel->handle(Request::create('/first')); + $pipeline = $kernel->reusablePipeline(); + + $this->assertNotNull($pipeline); + + $kernel->handle(Request::create('/second')); + + $this->assertSame($pipeline, $kernel->reusablePipeline()); + $this->assertSame(2, $resolutions); + } + public function testRequestStartedAtIsIsolatedBetweenConcurrentCoroutines(): void { $app = new Application; From 6f4a42919ba6e592193e14212ff9564e951b93b4 Mon Sep 17 00:00:00 2001 From: Albert Chen Date: Tue, 18 Aug 2026 16:42:47 +0800 Subject: [PATCH 32/34] perf(routing): reuse route middleware pipelines Resolved route middleware descriptors were cached, but Router still rebuilt their nested closure onion on every dispatch. Store the compiled closure and exact pipe snapshot on each Route, rebuilding only when the effective middleware list changes. Requests and middleware instances remain invocation-local. Container changes, controller flushes, serialization, dynamic middleware, and custom Router pipelines retain their invalidation paths. In the final benchmark, nine route middleware rise from 14,993 to 16,371 QPS (+9.2%) with p50 down 9.2%. --- src/routing/src/Route.php | 21 +++++++++ src/routing/src/Router.php | 20 ++++++--- tests/Routing/RouteMiddlewareCachingTest.php | 45 ++++++++++++++++++++ tests/Routing/RouterExtensionTest.php | 17 ++++++++ 4 files changed, 97 insertions(+), 6 deletions(-) diff --git a/src/routing/src/Route.php b/src/routing/src/Route.php index 586324924..72e408ff2 100755 --- a/src/routing/src/Route.php +++ b/src/routing/src/Route.php @@ -145,6 +145,21 @@ class Route */ public ?array $middlewareDescriptors = null; + /** + * The middleware descriptors represented by the compiled pipeline. + * + * @var null|array + */ + public ?array $middlewarePipelinePipes = null; + + /** + * The reusable route middleware pipeline. + * + * It captures only the onion structure; each invocation supplies its own + * request and resolves descriptor middleware without caching the instance. + */ + public ?Closure $middlewarePipeline = null; + /** * The compiled version of the route. * @@ -406,6 +421,8 @@ public function flushController(): void $this->computedMiddleware = null; $this->controller = null; $this->middlewareDescriptors = null; + $this->middlewarePipeline = null; + $this->middlewarePipelinePipes = null; $this->resolvedMiddleware = null; if ($this->isControllerAction()) { @@ -1562,6 +1579,8 @@ public function setContainer(Container $container): static $this->controller = null; $this->controllerDispatcher = null; $this->middlewareDescriptors = null; + $this->middlewarePipeline = null; + $this->middlewarePipelinePipes = null; $this->resolvedMiddleware = null; $this->shouldCacheControllerOnRoute = null; @@ -1597,6 +1616,8 @@ public function prepareForSerialization(): void $this->controller = null; $this->controllerDispatcher = null; $this->middlewareDescriptors = null; + $this->middlewarePipeline = null; + $this->middlewarePipelinePipes = null; $this->missing = null; $this->resolvedMiddleware = null; $this->router = null; diff --git a/src/routing/src/Router.php b/src/routing/src/Router.php index aa960438a..d6cbfcd1a 100644 --- a/src/routing/src/Router.php +++ b/src/routing/src/Router.php @@ -688,12 +688,20 @@ protected function runRouteWithinStack(Route $route, Request $request): mixed return $route->run(); } - return $this->newPipeline() - ->send($request) - ->through($middleware) - ->then(function ($request) use ($route) { - return $this->prepareResponse($request, $route->run()); - }); + // Compile lazily for each route, then rebuild if middleware resolution + // changes so the cached onion never retains a stale pipe list. + if ($route->middlewarePipeline === null + || $route->middlewarePipelinePipes !== $middleware + ) { + $route->middlewarePipelinePipes = $middleware; + $route->middlewarePipeline = $this->newPipeline() + ->through($middleware) + ->toClosure(function ($request) use ($route) { + return $this->prepareResponse($request, $route->run()); + }); + } + + return ($route->middlewarePipeline)($request); } /** diff --git a/tests/Routing/RouteMiddlewareCachingTest.php b/tests/Routing/RouteMiddlewareCachingTest.php index 43074d49c..b6bf045e1 100644 --- a/tests/Routing/RouteMiddlewareCachingTest.php +++ b/tests/Routing/RouteMiddlewareCachingTest.php @@ -54,6 +54,30 @@ public function testRouteDispatchCachesDescriptorsWithoutChangingGatheredMiddlew $this->assertSame(TestMiddleware::class, $route->middlewareDescriptors[0]->name); } + public function testRouteDispatchReusesPipelineWithoutCachingMiddlewareInstances(): void + { + $container = new Container; + $resolutions = 0; + $router = $this->getRouter($container); + $container->bind(TestMiddleware::class, function () use (&$resolutions): TestMiddleware { + ++$resolutions; + + return new TestMiddleware; + }); + $route = $router->get('foo', [ + 'middleware' => TestMiddleware::class, + 'uses' => fn () => 'ok', + ]); + + $this->assertSame('ok', $router->dispatch(Request::create('foo', 'GET'))->getContent()); + $pipeline = $route->middlewarePipeline; + $this->assertNotNull($pipeline); + + $this->assertSame('ok', $router->dispatch(Request::create('foo', 'GET'))->getContent()); + $this->assertSame($pipeline, $route->middlewarePipeline); + $this->assertSame(2, $resolutions); + } + public function testRouteWithoutMiddlewareDoesNotBuildDescriptors(): void { $router = $this->getRouter(); @@ -77,6 +101,24 @@ public function testDynamicGatheredMiddlewareIsNotReplacedByCachedDescriptors(): $this->assertSame(TestMiddleware::class, $first[0]->name); $this->assertSame(SecondTestMiddleware::class, $second[0]->name); $this->assertNull($route->middlewareDescriptors); + $this->assertNull($route->middlewarePipeline); + } + + public function testRouterSubclassRebuildsReusablePipelineWhenMiddlewareChanges(): void + { + $container = new Container; + $router = new DynamicMiddlewareRouter(new Dispatcher($container), $container); + $container->bind(ControllerDispatcherContract::class, fn ($app) => new ControllerDispatcher($app)); + $container->bind(CallableDispatcherContract::class, fn ($app) => new CallableDispatcher($app)); + $route = $router->get('foo', fn () => 'ok'); + + $this->assertSame('ok', $router->dispatch(Request::create('foo', 'GET'))->getContent()); + $firstPipeline = $route->middlewarePipeline; + $this->assertNotNull($firstPipeline); + + $this->assertSame('ok', $router->dispatch(Request::create('foo', 'GET'))->getContent()); + $secondPipeline = $route->middlewarePipeline; + $this->assertNotSame($firstPipeline, $secondPipeline); } public function testResolvedMiddlewareIsNullBeforeGathering(): void @@ -105,6 +147,7 @@ public function testFlushControllerClearsResolvedMiddleware(): void $route->flushController(); $this->assertNull($route->middlewareDescriptors); + $this->assertNull($route->middlewarePipeline); $this->assertNull($route->resolvedMiddleware); } @@ -123,6 +166,7 @@ public function testPrepareForSerializationClearsResolvedMiddleware(): void $route->prepareForSerialization(); $this->assertNull($route->middlewareDescriptors); + $this->assertNull($route->middlewarePipeline); $this->assertNull($route->resolvedMiddleware); } @@ -188,6 +232,7 @@ public function testSettingADifferentContainerClearsResolvedMiddleware(): void $this->assertNull($route->computedMiddleware); $this->assertNull($route->middlewareDescriptors); + $this->assertNull($route->middlewarePipeline); $this->assertNull($route->resolvedMiddleware); } diff --git a/tests/Routing/RouterExtensionTest.php b/tests/Routing/RouterExtensionTest.php index 39d9d3b79..2b3de0548 100644 --- a/tests/Routing/RouterExtensionTest.php +++ b/tests/Routing/RouterExtensionTest.php @@ -50,6 +50,23 @@ public function testBothDispatchPathsUseThePipelineAndMiddlewareHooks(): void $this->assertSame(2, $middleware->runs); } + public function testSubclassRouteDispatchReusesItsPipeline(): void + { + $container = new Container; + $middleware = new RouterTestMiddleware; + $container->instance(RouterTestMiddleware::class, $middleware); + $router = $this->router($container); + $router->get('hooked', static fn (): string => 'route') + ->middleware(RouterTestMiddleware::class); + + $this->assertSame('route', $router->dispatch(Request::create('/hooked', 'GET'))->getContent()); + $this->assertSame('route', $router->dispatch(Request::create('/hooked', 'GET'))->getContent()); + + $this->assertSame(1, $router->pipelineCreations); + $this->assertSame(2, $router->middlewareResolutions); + $this->assertSame(2, $middleware->runs); + } + public function testMiddlewareOverrideCanRetainRequiredMiddlewareWhenUserMiddlewareIsDisabled(): void { $container = new Container; From 87441ecbfb80fe01d1bb0b2e70d7c88bb6b18691 Mon Sep 17 00:00:00 2001 From: Albert Chen Date: Tue, 18 Aug 2026 16:43:08 +0800 Subject: [PATCH 33/34] perf(routing): match exact static routes directly Unconstrained exact routes still cloned RequestContext and constructed CompiledUrlMatcher for a static-table hash lookup. Read that table directly for the path, method, and slash common path, and remove it from matcher state after a known miss. Host, scheme, condition, port, encoded path, method, slash, fallback, and dynamic-route semantics keep their Symfony paths. In the final one-worker benchmark, bare static routing rises from 20,009 to 21,195 QPS (+5.9%) and controllers from 19,229 to 20,032 QPS (+4.2%). --- src/routing/src/CompiledRouteCollection.php | 166 +++++++++++++----- .../Routing/CompiledRouteCollectionTest.php | 67 +++++++ 2 files changed, 190 insertions(+), 43 deletions(-) diff --git a/src/routing/src/CompiledRouteCollection.php b/src/routing/src/CompiledRouteCollection.php index 8c96a0cf0..93175fec8 100644 --- a/src/routing/src/CompiledRouteCollection.php +++ b/src/routing/src/CompiledRouteCollection.php @@ -74,6 +74,21 @@ class CompiledRouteCollection extends AbstractRouteCollection */ protected bool $hasPortConstraints; + /** + * Whether exact static routes need only path, method, and slash checks. + * + * Host, scheme, condition, and port constraints retain Symfony matching. + */ + protected bool $supportsDirectStaticMatching; + + /** + * The compiled matcher state used after an exact static path miss. + * + * Removing the already-missed static table lets Symfony continue with dynamic + * routes without repeating the same static lookup. + */ + protected array $compiledWithoutStaticRoutes = []; + /** * A cache of route names grouped by the HTTP method they respond to, built from the route attributes. * @@ -113,6 +128,16 @@ public function __construct(array $compiled, array $attributes) $this->requiresScheme = $this->compiledRoutesRequireScheme($compiled); $this->requiresFullRequestContext = ($compiled[4] ?? null) !== null; $this->hasPortConstraints = $this->compiledRoutesHavePortConstraints($attributes); + $this->supportsDirectStaticMatching = ! empty($compiled[1]) + && ! ($compiled[0] ?? false) + && ! $this->requiresScheme + && ! $this->requiresFullRequestContext + && ! $this->hasPortConstraints; + + if ($this->supportsDirectStaticMatching) { + $this->compiledWithoutStaticRoutes = $compiled; + $this->compiledWithoutStaticRoutes[1] = []; + } } /** @@ -244,63 +269,86 @@ public function refreshActionLookups(): void public function match(Request $request): Route { $method = $request->getMethod(); - $host = $request->getHost(); $pathInfo = $request->getPathInfo(); + $path = rtrim($pathInfo, '/') ?: '/'; + $skipStaticRoutes = false; + $result = null; + $route = null; - if ($this->requiresFullRequestContext) { - $context = new RequestContext( - method: $method, - host: $host, - scheme: $request->getScheme(), - httpPort: $request->isSecure() ? 443 : (int) $request->getPort(), - httpsPort: $request->isSecure() ? (int) $request->getPort() : 443, - path: $pathInfo, - queryString: $request->server->get('QUERY_STRING', ''), - ); - } else { - $context = clone $this->requestContextPrototype; + if ($this->supportsDirectStaticMatching) { + $staticRoutes = $this->compiled[1][$path] ?? (str_contains($path, '%') ? null : false); - if ($method !== 'GET') { - $context->setMethod($method); - } + if (is_array($staticRoutes)) { + $result = $this->matchDirectStaticRoute($method, $path, $staticRoutes); - $context->setHost($host); + if ($result !== null) { + $route = $this->getByName($result['_route']); - if ($this->requiresScheme) { - $context->setScheme($request->getScheme()); + // Preserve the host validation performed by Symfony's request context path. + $request->getHost(); + } + } else { + $skipStaticRoutes = $staticRoutes === false; } } - $matcher = new CompiledUrlMatcher($this->compiled, $context); - $path = rtrim($pathInfo, '/') ?: '/'; + if ($result === null) { + $host = $request->getHost(); + + if ($this->requiresFullRequestContext) { + $context = new RequestContext( + method: $method, + host: $host, + scheme: $request->getScheme(), + httpPort: $request->isSecure() ? 443 : (int) $request->getPort(), + httpsPort: $request->isSecure() ? (int) $request->getPort() : 443, + path: $pathInfo, + queryString: $request->server->get('QUERY_STRING', ''), + ); + } else { + $context = clone $this->requestContextPrototype; - $route = null; - $result = null; + if ($method !== 'GET') { + $context->setMethod($method); + } - try { - if ($result = $matcher->match($path)) { - $route = $this->getByName($result['_route']); - } - } catch (MethodNotAllowedException $exception) { - if (! $this->hasDynamicRoutes && ! $this->hasPortConstraints) { - return $this->getRouteForMethods($request, $exception->getAllowedMethods()); - } + $context->setHost($host); - try { - return $this->routes->match($request); - } catch (NotFoundHttpException) { - } - } catch (ResourceNotFoundException) { - if (! $this->hasDynamicRoutes) { - throw new NotFoundHttpException(sprintf( - 'The route %s could not be found.', - $request->path() - )); + if ($this->requiresScheme) { + $context->setScheme($request->getScheme()); + } } + $matcher = new CompiledUrlMatcher( + $skipStaticRoutes ? $this->compiledWithoutStaticRoutes : $this->compiled, + $context + ); + try { - return $this->routes->match($request); - } catch (NotFoundHttpException) { + if ($result = $matcher->match($path)) { + $route = $this->getByName($result['_route']); + } + } catch (MethodNotAllowedException $exception) { + if (! $this->hasDynamicRoutes && ! $this->hasPortConstraints) { + return $this->getRouteForMethods($request, $exception->getAllowedMethods()); + } + + try { + return $this->routes->match($request); + } catch (NotFoundHttpException) { + } + } catch (ResourceNotFoundException) { + if (! $this->hasDynamicRoutes) { + throw new NotFoundHttpException(sprintf( + 'The route %s could not be found.', + $request->path() + )); + } + + try { + return $this->routes->match($request); + } catch (NotFoundHttpException) { + } } } @@ -337,6 +385,38 @@ public function match(Request $request): Route return $this->handleMatchedRoute($request, $route); } + /** + * Match an unconstrained exact path without constructing Symfony matcher state. + * + * Null means a static path existed but Symfony must resolve method or slash + * semantics. Encoded static paths use that fallback as well so dynamic + * requests pay only one preliminary hash lookup. + * + * @param array> $routes + * @return null|array + */ + protected function matchDirectStaticRoute(string $method, string $path, array $routes): ?array + { + $canonicalMethod = $method === 'HEAD' ? 'GET' : $method; + + foreach ($routes as [$result, , $requiredMethods, , $hasTrailingSlash]) { + if ($path !== '/' && $hasTrailingSlash) { + continue; + } + + if ($requiredMethods + && ! isset($requiredMethods[$canonicalMethod]) + && ! isset($requiredMethods[$method]) + ) { + continue; + } + + return $result; + } + + return null; + } + /** * Get routes from the collection by method. * diff --git a/tests/Integration/Routing/CompiledRouteCollectionTest.php b/tests/Integration/Routing/CompiledRouteCollectionTest.php index e9238e8ae..d0d05866d 100644 --- a/tests/Integration/Routing/CompiledRouteCollectionTest.php +++ b/tests/Integration/Routing/CompiledRouteCollectionTest.php @@ -11,6 +11,7 @@ use Hypervel\Routing\RouteCollection; use Hypervel\Routing\Router; use Hypervel\Support\Arr; +use Symfony\Component\HttpFoundation\Exception\SuspiciousOperationException; use Symfony\Component\HttpKernel\Exception\MethodNotAllowedHttpException; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; use Symfony\Component\Routing\RequestContext as SymfonyRequestContext; @@ -417,6 +418,60 @@ public function testCompiledMatchBindsEmptyParameterState(): void $this->assertSame([], $route->parameters()); } + public function testUnconstrainedStaticMatchPreservesHostValidation(): void + { + $this->routeCollection->add( + $this->newRoute('GET', '/status', [ + 'uses' => 'FooController@index', + 'as' => 'status', + ]) + ); + + $request = StaticMatchTrackingRequest::create('/status', 'GET'); + $route = $this->collection()->match($request); + + $this->assertSame('status', $route->getName()); + $this->assertSame(1, $request->hostReads); + } + + public function testUnconstrainedStaticMatchRejectsAnInvalidHost(): void + { + $this->routeCollection->add( + $this->newRoute('GET', '/status', [ + 'uses' => 'FooController@index', + 'as' => 'status', + ]) + ); + $request = Request::create('/status', 'GET'); + $request->headers->set('HOST', 'invalid host'); + + $this->expectException(SuspiciousOperationException::class); + + $this->collection()->match($request); + } + + public function testStaticCompiledFallbackYieldsToDynamicNonFallbackRoute(): void + { + $this->routeCollection->add( + $this->newRoute('GET', '/status', [ + 'uses' => 'FooController@index', + 'as' => 'compiled-fallback', + ])->fallback() + ); + $routes = $this->collection(); + $routes->add( + $this->newRoute('GET', '/status', [ + 'uses' => 'FooController@index', + 'as' => 'dynamic', + ]) + ); + + $this->assertSame( + 'dynamic', + $routes->match(Request::create('/status', 'GET'))->getName() + ); + } + public function testCompiledMatchUsesAnOverriddenRouteBindMethod(): void { $router = new BindTrackingRouter($this->app->make('events'), $this->app); @@ -827,3 +882,15 @@ public function bind(Request $request): static return parent::bind($request); } } + +class StaticMatchTrackingRequest extends Request +{ + public int $hostReads = 0; + + public function getHost(): string + { + ++$this->hostReads; + + return parent::getHost(); + } +} From d544d1ef391db7346099e7c2475c833e98b38ee4 Mon Sep 17 00:00:00 2001 From: Albert Chen Date: Tue, 18 Aug 2026 18:05:41 +0800 Subject: [PATCH 34/34] fix(foundation): reset HTTP kernel caches on application swap --- src/foundation/src/Http/Kernel.php | 3 +++ tests/Foundation/Http/KernelTest.php | 27 +++++++++++++++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/src/foundation/src/Http/Kernel.php b/src/foundation/src/Http/Kernel.php index 90e830e90..79d5011c5 100644 --- a/src/foundation/src/Http/Kernel.php +++ b/src/foundation/src/Http/Kernel.php @@ -800,6 +800,9 @@ public function getApplication(): Application public function setApplication(Application $app): static { $this->app = $app; + $this->terminableMiddleware = []; + $this->middlewarePipelineStack = []; + $this->middlewarePipeline = null; return $this; } diff --git a/tests/Foundation/Http/KernelTest.php b/tests/Foundation/Http/KernelTest.php index a882f7da8..319f73f8c 100644 --- a/tests/Foundation/Http/KernelTest.php +++ b/tests/Foundation/Http/KernelTest.php @@ -540,6 +540,33 @@ public function reusablePipeline(): ?Closure $this->assertSame(2, $resolutions); } + public function testSetApplicationClearsApplicationSpecificMiddlewareCaches(): void + { + $kernel = new class(new Application, m::mock(Router::class)) extends Kernel { + public function primeMiddlewareCaches(): void + { + $this->terminableMiddleware = ['test-middleware' => false]; + $this->middlewarePipelineStack = ['test-middleware']; + $this->middlewarePipeline = static fn (): null => null; + } + + public function middlewareCaches(): array + { + return [ + $this->terminableMiddleware, + $this->middlewarePipelineStack, + $this->middlewarePipeline, + ]; + } + }; + $application = new Application; + $kernel->primeMiddlewareCaches(); + + $this->assertSame($kernel, $kernel->setApplication($application)); + $this->assertSame($application, $kernel->getApplication()); + $this->assertSame([[], [], null], $kernel->middlewareCaches()); + } + public function testRequestStartedAtIsIsolatedBetweenConcurrentCoroutines(): void { $app = new Application;