A PHP application's reflection-based container rebuilds its service graph on every request; what does that really cost, and how do production containers avoid it?
answer
- share-nothing: nothing survives the request
- reflecting a class autoloads it
- cost scales with services, not calls
- generate PHP code at build time
- OPcache keeps the generated file
basics
~20 sEach request starts empty, so the container re-reflects and autoloads every class it inspects, even unused ones. Production containers resolve the graph once at build time and write plain PHP factory code that OPcache serves.
solid answer
~50 sReflection itself reads metadata the engine already holds, so one `ReflectionClass` call is cheap; the cost is in what an autowiring container does around it. Under PHP's usual share-nothing model nothing built in one request survives to the next, so a container that autowires at runtime repeats the whole walk — `new ReflectionClass()` for every service it inspects, which **autoloads** each class file, then `getParameters()`, type checks and recursion — on every request. If it validates the full graph at boot, it touches classes the request never needs. The production fix is to **compile** the container: a build or cache-warm step runs the reflection once and generates a PHP class of plain `new` calls, or a `var_export()`ed array of definitions. That file is `require`d at runtime and OPcache keeps its compiled form in shared memory, so the steady state uses no reflection at all.
code
php · 19 lines<?php
// var/cache/container.php - generated at build time, never edited by hand
declare(strict_types=1);
final class CompiledContainer
{
private array $shared = [];
public function get(string $id): object
{
return $this->shared[$id] ??= match ($id) {
InvoiceMailer::class => new InvoiceMailer($this->get(SmtpMailer::class), $this->get(InvoiceRepository::class)),
SmtpMailer::class => new SmtpMailer('smtp.example.test', 587),
InvoiceRepository::class => new InvoiceRepository($this->get(PDO::class)),
PDO::class => new PDO('mysql:host=db;dbname=app', 'app', getenv('DB_PASSWORD') ?: ''),
default => throw new LogicException("Unknown service $id"),
};
}
}go deeper
Know that reflection-based containers do extra work at runtime and that frameworks often generate cached code to avoid it.
Explain where the cost actually comes from: autoloading, recursion over many services, and repetition because requests share nothing.
Measure container overhead against the rest of the request, move validation to build time, and set up generation and invalidation in the deploy pipeline.
Weigh build-time compilation against developer experience and deploy complexity, deciding where the boundary sits for a given application size.
## What reflection costs in PHP PHP's Reflection classes do not parse source code. When you write `new ReflectionClass(Invoice::class)`, the engine already has the class's compiled description — its methods, properties, parameter types — and the reflection object is a thin wrapper around it. A single call is cheap. The expense in real applications comes from three things around those calls: - **Autoloading.** Reflecting a class requires the class to be loaded. A container that inspects its whole service graph pulls in every class file, including services this request never uses. - **Repetition.** Autowiring walks constructors recursively: for each service, `getConstructor()`, `getParameters()`, `getType()`, `isBuiltin()`, recursion, array building, `newInstanceArgs()`. Multiplied by hundreds of services, this becomes measurable. - **The request lifecycle.** Under PHP-FPM and similar setups, each request begins with no objects from the previous one. Whatever the container figured out is discarded at the end of the request and figured out again at the start of the next. ## Why caching in memory is not the answer In a long-running process one would compute the graph once and keep it in memory. In classic PHP there is no such process-level object memory between requests. What *does* persist is **OPcache**: compiled PHP files stay in shared memory across requests. So the durable cache for a PHP container is not a data structure but **generated PHP code**. ## The compiled-container pattern 1. **Build step.** A CLI command (run in CI or during deploy) loads the configuration, reflects every service, resolves the full graph and validates it — missing bindings, unresolvable scalars, cycles. 2. **Code generation.** It writes a PHP file: either a class with one method per service containing plain `new` expressions, or an array of definitions exported with `var_export()`. 3. **Runtime.** The application `require`s the generated file. OPcache serves its compiled opcodes; services are built only when first requested, with no reflection. 4. **Invalidation.** The file is regenerated on deploy; in development the container checks file modification times or rebuilds on demand. | Approach | Reflection per request | Classes autoloaded per request | Errors found | |---|---|---|---| | Runtime autowiring, lazy | only for services used | only those used | when the service is first requested | | Runtime autowiring, validate all at boot | whole graph | all services | at boot, every request | | Compiled container | none | only those used | at build time | ## Measuring before optimising Reflection is rarely the whole story. Profile a request, look at time spent in the container compared with I/O and database work, and check how many files were included. A small application with a dozen services may see no benefit from compilation; a large one with hundreds of services and eager validation often does. ## Related reflection hot spots - **Hydrators and serializers** that reflect each class on every object: cache the per-class `ReflectionProperty` list in a static array for the request, or generate hydrator code. - **Attribute scanners** for routing or validation: same pattern — scan at build time, export the result. - **Deep copying or comparing with reflection** in hot loops: prefer explicit methods. ## What to keep in the generated file - Only data PHP can export as literals: class names, parameter names, scalar configuration values, arrays. `var_export()` handles these directly, and the result is valid PHP that OPcache can cache. - **No secrets baked in** — read them from the environment at runtime, as the example's `getenv()` call does, so the generated file is safe to keep in a build artifact. - A hash or timestamp of the inputs, so the application can detect a stale file in development and rebuild it. ## Takeaways - One reflection call is cheap; a reflected graph rebuilt per request is not. - Share-nothing means the cache must be a file, and OPcache makes that file fast. - Compile once, validate at build time, and ship the generated code.
- Why does validating the whole container graph at boot slow every PHP request?Validation must reflect every service, and reflecting a class loads its file through the autoloader. Under share-nothing requests that work is repeated each time, so a request serving one endpoint pays for loading and inspecting hundreds of classes it never uses. Do that validation at build time instead.
- How should a hydrator that maps rows to entities limit reflection overhead within one request?Reflect each entity class once and keep its `ReflectionProperty` objects in a static per-class array, reusing them for every row. For heavier workloads, generate hydrator code per class at build time so the steady state uses plain property writes.
saying these in an interview costs you the question
- Each ReflectionClass call re-parses the class's source file.
- PHP-FPM keeps the container's resolved objects between requests automatically.
- Reflecting a class never triggers the autoloader.
- Reflection is always the main cost, so compile every container regardless of size.
- OPcache caches the objects a container builds, not just compiled code.