Skip to content

(feat) Router: compile controller actions into cached "action plans" instead of using Reflection on every request - #341

Merged
techmahedy merged 3 commits into
doppar:4.xfrom
techmahedy:techmahedy-4.x
Sep 28, 2026
Merged

techmahedy merged 3 commits into
doppar:4.xfrom
techmahedy:techmahedy-4.x

Conversation

@techmahedy

@techmahedy techmahedy commented Sep 26, 2026 •

Copy link
Copy Markdown
Member

Pull Request Checklist

Q A
Branch? 4.x
Bug fix? No
New feature? Yes
Deprecations? no
Issues -
License MIT

Description

The Router already caches route definitions, but every dispatch still read the action with the Reflection API: #[Resolver] (class and method), #[Transaction], and per parameter #[Model], #[BindPayload] and #[Bind], plus the constructor and every parameter type. None of that changes between requests.

This PR reads an action once and keeps the result as plain data (an action plan). The Router resolves arguments from the plan without Reflection or attribute instantiation. Everything that depends on the request still runs per request: container bind()/singleton(), make(), model lookup, payload validation, and the transaction.

Design

Three tiers, cheapest first:

  1. In-process memo — plans are kept for the life of the process (a WeakMap for closures). In worker mode this is once per action per worker.
  2. Compiled file — route:cache writes storage/framework/cache/actions.php (a var_export array, written to a temp file and renamed, then opcache_invalidated). opcache serves it from shared memory.
  3. Planner — ActionPlanner builds a plan with Reflection. It is the fallback, and the only thing that ever produced a plan, so tiers 1 and 2 cannot change behavior.

A compiled file with a different FORMAT, or one that is missing, unreadable or corrupt, is ignored and the plan is rebuilt. It can never fail a request. route:clear removes it.

New classes: Phaseolies\Support\Router\Plan\ActionPlanner and ActionPlanStore. Router gains useActionPlans() / actionPlans() and resolveAction() now works from a plan. Plans contain no objects and no closures, only scalars and arrays. A parameter whose default value cannot be stored as data (an enum case, new in an initializer, an unresolvable constant expression) is flagged lazyDefault and read from Reflection only if the argument is actually needed, exactly as before.

Measured performance

resolveAction() for a controller with class + method #[Resolver], a constructor dependency, and an action with a Request, a #[Bind] parameter, a class dependency, a route parameter and an optional default. php -S with shared-memory opcache, a fresh PHP request per sample, 400 samples, optimized classmap:

first call in a request second call
Before 72.3 µs 31.2 µs
After, route:cache compiled 56.8 µs (-21%) 18.9 µs (-39%)
After, no route cache 99.5 µs (+27 µs) 19.8 µs (-37%)

Warm worker process (plan already memoized): 19.2 µs before, 11.5 µs after (-40%).

Honest reading:

  • With route:cache (the production setup) and in worker mode this is a clear win.
  • Without any cache, a one-action-per-request FPM request pays about 27 µs more (about 0.03 ms). The plan reads everything an action could need up front, where the old code read lazily and skipped what a given action did not use, and it loads two extra classes. That is the price of the plan existing at all; it disappears as soon as route:cache is used, which the docs already recommend for production.
  • The absolute numbers are small next to a full request. The point is that reflection cost is now a deploy-time cost instead of a per-request cost, and that it stops growing with the number of attributes and parameters an action has.

Tests

tests/Router/ActionPlan/ (263 router tests in total, full suite 3140 green, PHPStan clean):

Behavior changes (please read)

  • The following protected Router methods are removed: processAttributesClassDependencies, processAttributesMethodDependencies, resolveConstructorDependencies, resolveActionDependencies, resolveParameters, handleBindPayloadAttribute, handleBindAttribute, handleModelAttribute, getTransactionConfig. Subclasses overriding them need to move to the plan-based resolvePlannedParameters() family.
  • A parameter with a union or intersection type now resolves gracefully (treated as a builtin, so its default or the route value is used) where the old code threw a ReflectionException from getName().
  • A callback that is neither a closure, an [class, method] array nor a Class@method string now throws InvalidArgumentException.
  • The class-level #[Resolver] of an action is no longer applied when the method does not exist (the request fails with "Method ... does not exist" first).
  • After changing a controller's parameters or attributes on a deployment that uses route:cache, re-run route:cache (or route:clear), as with any route change.

Docs

Routing → Route Caching → "Action Plan Cache" and the Deployment page. Marked "Available from v4.1.0".

Checklist

  • Tests have been added or updated
  • Documentation has been updated if necessary
  • Code follows the project coding standards
  • All tests pass locally

@techmahedy techmahedy self-assigned this Sep 26, 2026
@techmahedy techmahedy added enhancement New feature or request feat new feature labels Sep 26, 2026
@techmahedy
techmahedy merged commit d65b7e1 into doppar:4.x Sep 28, 2026
14 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request feat new feature

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant