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

> Read attributes once per class into a cached mapping plan rather than per request, and compile to generated PHP only if the profile still shows reflection.

- Asked: 2026-10-19
- Answered: 2026-10-23
- Asked by: Arda
- Tags: php, performans
- Source: https://muhammetsafak.com/just-ask/keep-reflection-cost-under-control-mapping-layer-production/
- Language: en-US
- Author: Muhammet Şafak

---
**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?


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

## Short answer

[Read the attributes once](/blog/coding-with-php-8-0-attributes-and-nullsafe-operator/), 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.
   ```php
   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

- [Coding with PHP 8.0: Attributes and the Nullsafe Operator](/blog/coding-with-php-8-0-attributes-and-nullsafe-operator/) — Blog
- [My containers keep getting OOMKilled—how do I set memory limits correctly for PHP-FPM and Go?](https://muhammetsafak.com/just-ask/containers-oomkilled-set-memory-limits-php-fpm-go/) — Just Ask
- [For concurrent HTTP calls, should I reach for raw Fibers, ReactPHP/AMPHP, or Swoole?](https://muhammetsafak.com/just-ask/concurrent-http-calls-raw-fibers-reactphp-amphp-swoole/) — Just Ask
- [When should I switch to a covering index with INCLUDE to get an index-only scan?](https://muhammetsafak.com/just-ask/switch-covering-index-include-get-index-only-scan/) — Just Ask
