Skip to content

Memoize route matching to eliminate duplicate match() calls per request #61

@markshust

Description

@markshust

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:

  1. 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.
  2. 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:

  1. 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.
  2. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions