Skip to content
Muhammet Şafak
tr
Asked by: Arda Answered:

How do I keep reflection cost under control in an attribute-based mapping layer in production?


Question

I wrote my own serializer that reads PHP attributes and maps them onto DTOs/arrays. The problem is that on every request I open a `ReflectionClass` for the relevant class and re-read the attributes. As things scale (high RPS, deep object graphs) I'm worried about this per-request reflection overhead. Under FPM every request starts cold, so it seems to repeat the work each time. How do I keep this cost under control in production?

Answer

Short answer: reflection itself isn’t your enemy — running it on every request is.

Short answer

Read the attributes once, compile the mapping into a cacheable “plan” (ideally generated PHP), and the reflection cost effectively disappears from the hot path.

Why

  1. Worker cache vs runtime cache. In a long-lived worker (Octane/RoadRunner/FrankenPHP) a static array cache survives across requests — reflection runs once per worker lifetime. In classic FPM every request is cold, so you need APCu or a compiled cache file.
  2. Mind the reflection/OPcache interplay. OPcache does not cache reflection results; it only caches compiled code. So the win comes from moving work out of reflection and into generated/compiled code, or into a persistent cache.

What to do

  1. Measure before you optimize. Profile with Blackfire/XHProf. Reflection over a handful of classes per request is often in the microseconds; if it isn’t showing up in your profile, don’t over-engineer. But at high RPS with deep graphs the total does add up fast.
  2. Cache the parsed metadata. The expensive part isn’t the mapping — it’s ReflectionClass + reading attributes. Parse each class once and store the resulting flat “mapping plan” in a static cache (APCu/PSR-16) keyed by class name.
    final class MappingPlanCache
    {
        /** @var array<class-string, MappingPlan> */
        private static array $plans = [];
    
        public function planFor(string $class): MappingPlan
        {
            // Compiled only once per class within a request/worker.
            return self::$plans[$class] ??= $this->compile($class);
        }
    
        private function compile(string $class): MappingPlan
        {
            $ref = new \ReflectionClass($class);
            // attributes are read once here and turned into a flat plan
            return MappingPlan::fromReflection($ref);
        }
    }
  3. Compile to generated PHP for the fastest path. Symfony Serializer’s default ObjectNormalizer and Doctrine’s hydration step still go through reflection under the hood — if you want a reflection-free class that maps via direct property access, you have to build that compile step yourself. OPcache then serves it as bytecode. That’s exactly what “production mode” means.
  4. Tie cache invalidation to the deploy. Key the compiled cache by a deploy hash, or clear it on deploy. Don’t invalidate per-request (that defeats the point), and don’t leave it stale across schema changes either.

Bottom line: personally I’d start with a per-worker static cache (one line: ??=), measure, and only reach for generated-PHP compilation if the profile still shows reflection in the hot path. Under Octane the static cache alone usually makes the problem vanish; under FPM you add APCu on top. Don’t hand-roll a compiler you don’t need — measure first, and add the layer when the measurement forces you to.

Related Reading

Comments

Sign in with your GitHub account to join the discussion. Comments are stored in GitHub Discussions.

More Questions

All questions

Search the site

Start typing to search posts, projects and pages.

Esc to close Powered by Pagefind