Problem
When a global middleware needs to inspect the route a request will resolve to (for example, to read a method-level attribute), it has to call RouteMatcherInterface::match() itself. The router then matches the same request a second time downstream. Result: every such request iterates the route table twice.
This was surfaced while reviewing #58 (marko/page-cache):
PageCacheMiddleware runs as global middleware and asks CacheabilityChecker::getRouteAttribute() whether the matched route's controller method carries #[Cacheable].
CacheabilityChecker calls \$this->routeMatcher->match(\$request->method(), \$request->path()) (packages/page-cache/src/CacheabilityChecker.php:59) — necessary, since it has no other way to reach the controller class to reflect on.
Router::handle() (packages/routing/src/Router.php:43) then calls \$this->matcher->match(...) again on the same request.
Two factors compound the cost:
Router constructs its own private matcher (packages/routing/src/Router.php:33: \$this->matcher = new RouteMatcher(\$routes);) instead of resolving RouteMatcherInterface from the container. Even if RouteMatcher had an instance-level cache, the Router and the middleware would not share it.
RouteMatcher::match() has no memoization (packages/routing/src/RouteMatcher.php:13-31) — it loops the routes-by-method list and runs preg_match() per route, every time it is called.
For most apps this is invisible (small route tables, fast regex). For apps with hundreds of routes, or for any new global middleware that wants to peek at the upcoming route (auth context, rate-limit policy, feature flags, observability), the duplicate match is gratuitous work paid on every request.
Proposed Solution
Two small changes in marko/routing:
- Memoize inside
RouteMatcher::match() by (method, path) so a second call within the same request is O(1). The key shape is request-scoped naturally because RouteMatcher is a request-lifetime service in the container.
- Have
Router resolve RouteMatcherInterface from the container rather than new RouteMatcher(\$routes) in the constructor. Then the matcher used by middleware and the matcher used by the router are the same instance, and the memoization in (1) benefits both sides.
After both changes, a global middleware that calls match() to inspect the upcoming route costs nothing extra — the router's later call is a cache hit.
Alternatives Considered
- Attach the matched route to the
Request. Have middleware do the first match and store the result on the request; have Router::handle() check for it before matching. Rejected because it couples Request to routing internals and assumes a specific middleware order. Memoization inside RouteMatcher is invisible to callers and doesn't require any contract changes.
- Pass the matched route down through the middleware pipeline. Would mean changing
MiddlewareInterface::handle() to accept the matched route. Big surface change for a small win, and breaks third-party middleware.
- Leave it. Defensible — the duplicate match is
O(n_routes) with a fast regex, often negligible. Filing this for visibility, not as a blocker. The fix is small and pays off as the framework grows.
Discovery Context
Found while reviewing #58 (page-cache packages). The page-cache middleware works correctly today and shipped as-is; this issue is about removing avoidable work that any future "peek at the route before it executes" middleware will also incur.
Problem
When a global middleware needs to inspect the route a request will resolve to (for example, to read a method-level attribute), it has to call
RouteMatcherInterface::match()itself. The router then matches the same request a second time downstream. Result: every such request iterates the route table twice.This was surfaced while reviewing #58 (
marko/page-cache):PageCacheMiddlewareruns as global middleware and asksCacheabilityChecker::getRouteAttribute()whether the matched route's controller method carries#[Cacheable].CacheabilityCheckercalls\$this->routeMatcher->match(\$request->method(), \$request->path())(packages/page-cache/src/CacheabilityChecker.php:59) — necessary, since it has no other way to reach the controller class to reflect on.Router::handle()(packages/routing/src/Router.php:43) then calls\$this->matcher->match(...)again on the same request.Two factors compound the cost:
Routerconstructs its own private matcher (packages/routing/src/Router.php:33:\$this->matcher = new RouteMatcher(\$routes);) instead of resolvingRouteMatcherInterfacefrom the container. Even ifRouteMatcherhad an instance-level cache, the Router and the middleware would not share it.RouteMatcher::match()has no memoization (packages/routing/src/RouteMatcher.php:13-31) — it loops the routes-by-method list and runspreg_match()per route, every time it is called.For most apps this is invisible (small route tables, fast regex). For apps with hundreds of routes, or for any new global middleware that wants to peek at the upcoming route (auth context, rate-limit policy, feature flags, observability), the duplicate match is gratuitous work paid on every request.
Proposed Solution
Two small changes in
marko/routing:RouteMatcher::match()by(method, path)so a second call within the same request isO(1). The key shape is request-scoped naturally becauseRouteMatcheris a request-lifetime service in the container.RouterresolveRouteMatcherInterfacefrom the container rather thannew RouteMatcher(\$routes)in the constructor. Then the matcher used by middleware and the matcher used by the router are the same instance, and the memoization in (1) benefits both sides.After both changes, a global middleware that calls
match()to inspect the upcoming route costs nothing extra — the router's later call is a cache hit.Alternatives Considered
Request. Have middleware do the first match and store the result on the request; haveRouter::handle()check for it before matching. Rejected because it couplesRequestto routing internals and assumes a specific middleware order. Memoization insideRouteMatcheris invisible to callers and doesn't require any contract changes.MiddlewareInterface::handle()to accept the matched route. Big surface change for a small win, and breaks third-party middleware.O(n_routes)with a fast regex, often negligible. Filing this for visibility, not as a blocker. The fix is small and pays off as the framework grows.Discovery Context
Found while reviewing #58 (page-cache packages). The page-cache middleware works correctly today and shipped as-is; this issue is about removing avoidable work that any future "peek at the route before it executes" middleware will also incur.