Reflection, Metadata, And Runtime Inspection
What Reflection Is For
Reflection answers questions your code cannot answer by just reading the source, because the source isn't known until runtime — it was passed in as a class name string, loaded from a plugin, or built from user configuration.
<?php
declare(strict_types=1);
class Product
{
public function __construct(
public readonly string $name,
public readonly float $price,
) {}
}
$reflection = new ReflectionClass(Product::class);
foreach ($reflection->getConstructor()->getParameters() as $param) {
$type = $param->getType();
echo $param->getName() . ': ' . ($type instanceof ReflectionNamedType ? $type->getName() : 'mixed') . "\n";
}
// Prints:
// name: string
// price: float
Nothing here required knowing Product had a name and a price when this code was written. A dependency injection container uses exactly this loop to figure out what to construct and pass to new Product(...) for any class it is handed — that is how a container built years before Product existed can still instantiate it correctly.
The Core Reflection Classes
The API mirrors PHP's own structure: a class for each kind of thing you might want to inspect.
ReflectionClass— properties, methods, constants, interfaces, parent class, attributes.ReflectionMethod/ReflectionFunction— parameters, return type, visibility, whether it's static.ReflectionProperty— type, visibility, default value, whether it's promoted or readonly.ReflectionParameter— type, whether it's optional, its default value.ReflectionNamedType/ReflectionUnionType— the type-hint information attached to a parameter, property, or return type.
They compose: ReflectionClass::getMethods() returns ReflectionMethod[]; each method's getParameters() returns ReflectionParameter[]; each parameter's getType() returns a ReflectionNamedType or null. Walking a class's full shape is a matter of descending through these objects.
Reading Attributes
PHP attributes (#[...]) are metadata attached to a class, method, or property that does nothing on its own — it is inert until something reads it with Reflection.
<?php
declare(strict_types=1);
#[Attribute]
class Route
{
public function __construct(public readonly string $path) {}
}
class ProductController
{
#[Route('/products')]
public function index(): string
{
return 'listing';
}
}
$method = new ReflectionMethod(ProductController::class, 'index');
foreach ($method->getAttributes(Route::class) as $attribute) {
$route = $attribute->newInstance();
echo $route->path . "\n";
}
// Prints:
// /products
This is how attribute-driven routers, ORMs, and validators work: the framework scans classes with Reflection at startup (or at build time), reads the attributes off methods and properties, and builds its own internal maps (URL → controller method, column → property) from what it finds. The attribute is just data; Reflection is what turns it into behavior.
Instantiating And Invoking Through Reflection
Reflection can also construct objects and call methods, including ones that are not public — this is what makes it powerful and what makes it dangerous.
<?php
declare(strict_types=1);
class Counter
{
private int $value = 0;
private function increment(): void
{
$this->value++;
}
public function value(): int
{
return $this->value;
}
}
$counter = new Counter();
$method = new ReflectionMethod($counter, 'increment');
$method->setAccessible(true); // no-op since PHP 8.1, private methods are already reachable this way
$method->invoke($counter);
$method->invoke($counter);
echo $counter->value();
// Prints:
// 2
increment() is private. Ordinary code ($counter->increment() from outside the class) would fail with a visibility error — that error is the language enforcing the encapsulation lesson from 03-object-property-method-and-static-access. Reflection bypasses it entirely. That's legitimate for a serialization library restoring private state, or a test framework asserting on internal state the class deliberately doesn't expose — and a code smell everywhere else. If application code reaches for ReflectionMethod::invoke() on a private method to get a feature working, the actual fix is almost always to add a proper public method or constructor parameter instead.
Where Reflection Belongs
Three legitimate use cases account for nearly all real Reflection usage:
- Dependency injection containers — inspecting constructor parameter types to autowire dependencies, as in the opening example.
- Serializers and ORMs — mapping object properties to array keys, JSON fields, or database columns without every class writing that mapping by hand.
- Attribute-driven frameworks and test runners — discovering routes, validation rules, or test methods by scanning attributes, as in the routing example.
All three share a shape: a small amount of framework code inspects a large, changing set of application classes it cannot know about in advance. That is a genuinely different problem from "my controller needs to read a property" — and application code with a known class at write time should just use ->, ::, or [] as covered earlier in this track.
Costs Of Reflection
Reflection is markedly slower than direct access — every ReflectionClass::getMethods() call walks metadata that a direct call skips entirely — which is why containers and ORMs typically reflect once (often at startup, or ahead of time in a compiled cache) and reuse the result, rather than reflecting on every request.
It also breaks static analysis. A tool like PHPStan can verify $product->name exists on Product by reading the class declaration; it cannot verify that $reflection->getProperty($someVariable)->getValue($obj) will succeed, because the property name is a runtime string. Reflection-heavy code trades compile-time safety for runtime flexibility — worth it inside a framework's small trusted core, and a liability if it spreads into application code where static checking is the main defense against typos.
And because Reflection can reach private members and call inaccessible methods, feeding it an untrusted class name or method name is a security boundary, not just a style concern — treat new ReflectionClass($userSuppliedString) the same way you'd treat any other case of letting untrusted input choose which code runs.
What To Check
Before moving on, make sure you can:
- explain what problem Reflection solves that
[],->, and::cannot - walk
ReflectionClass→ReflectionMethod/ReflectionProperty→ReflectionParameter/ReflectionNamedTypeto describe a class's shape at runtime - read an attribute off a class or method using
getAttributes()andnewInstance() - explain why
ReflectionMethod::invoke()can call private methods, and why that's rarely the right fix in application code - name the three legitimate Reflection use cases and explain why they differ from ordinary property/method access
- describe Reflection's two costs: runtime performance and loss of static analysis
What You Should Be Able To Do
After this lesson, you should be able to use the Reflection API to inspect a class's constructor, methods, properties, and attributes at runtime, recognize when a framework or tool is using Reflection appropriately, and recognize when application code has reached for Reflection to route around encapsulation instead of fixing the actual design gap — a decision informed by everything else this track covered about how PHP's access operators are meant to be used.
Practice
Practice: Build A Minimal Autowiring Container
Requirements:
- If the constructor has no parameters, just
newthe class directly. - For each constructor parameter, use
ReflectionParameter::getType()to find its class name, then recursively call$this->make(...)on that class name to build the dependency. - If a parameter has no type, or its type is a scalar (
string,int, etc.) rather than a class, throw anInvalidArgumentExceptionnaming the parameter — the container cannot guess a value for it.
Demonstrate it working on this dependency graph, where Container::make(OrderService::class) should build a working OrderService with a real Logger and Mailer inside it, without you writing any wiring code by hand:
class Logger {}
class Mailer {
public function __construct(private Logger $logger) {}
}
class OrderService {
public function __construct(private Mailer $mailer, private Logger $logger) {}
}
Show solution
Solution
<?php
declare(strict_types=1);
class Container
{
public function make(string $class): object
{
$reflection = new ReflectionClass($class);
$constructor = $reflection->getConstructor();
if ($constructor === null || $constructor->getNumberOfParameters() === 0) {
return new $class();
}
$arguments = [];
foreach ($constructor->getParameters() as $param) {
$type = $param->getType();
if (!$type instanceof ReflectionNamedType || $type->isBuiltin()) {
throw new InvalidArgumentException(
"Cannot autowire parameter \${$param->getName()} of {$class}: no resolvable class type."
);
}
$arguments[] = $this->make($type->getName());
}
return $reflection->newInstanceArgs($arguments);
}
}
class Logger {}
class Mailer
{
public function __construct(private Logger $logger) {}
}
class OrderService
{
public function __construct(private Mailer $mailer, private Logger $logger) {}
}
$container = new Container();
$order = $container->make(OrderService::class);
echo get_class($order) . "\n";
// Prints:
// OrderService
Why This Works
ReflectionClass::getConstructor() returns null for a class with no declared constructor (like Logger) — the base case that stops the recursion. For every other class, each parameter's ReflectionNamedType is inspected: isBuiltin() is true for scalar types (string, int, array, ...) and false for class/interface types, which is exactly the distinction the container needs to decide "can I build this myself?" versus "I need a value I have no way to guess."
The recursive call to $this->make($type->getName()) is what lets OrderService — which depends on Mailer, which itself depends on Logger — get built in one top-level call. The container never needed to know that dependency chain existed; it discovered it by reading types, the same idea as the opening Product example in the article but chained through multiple levels.
newInstanceArgs() is Reflection's way of calling a constructor with a computed array of arguments — the Reflection equivalent of new $class(...$arguments).
Design Note
- Owner: whoever maintains the container/bootstrap code — application code that calls
$container->make()should never need to know autowiring is happening. - Trust boundary:
$classshould come from application configuration or a fixed set of known service classes, never directly from user input — the article's security note applies here: an attacker-controlled class name handed tomake()can trigger construction of arbitrary classes. - Positive path:
make(OrderService::class)returns a fully wiredOrderService. - Rejected path: a class with an untyped or scalar-typed constructor parameter throws
InvalidArgumentExceptionnaming the parameter, rather than silently passingnull. - Evidence: a test asserting
make(OrderService::class)returns an instance whose injectedMailer/Loggerare non-null, plus a test asserting the scalar-parameter case throws.
Practice: Diagnose A Reflection Property Bug
class Money
{
public function __construct(private readonly int $cents) {}
public function cents(): int
{
return $this->cents;
}
}
function setCents(Money $money, int $newCents): Money
{
$prop = new ReflectionProperty($money, 'amountCents');
$prop->setAccessible(true);
$prop->setValue($money, $newCents);
return $money;
}
$price = new Money(500);
$updated = setCents($price, 750);
echo $updated->cents();
Running it produces:
Fatal error: Uncaught ReflectionException: Property Money::$amountCents does not exist
Task
Diagnose why this fails, and explain:
- the immediate cause of the exception;
- why PHP's normal type/property checking didn't catch this mistake before runtime;
- the deeper design problem with using
ReflectionPropertyhere at all, given that$centsisreadonly; - a fix that doesn't involve Reflection.
Show solution
Diagnosis
Immediate cause. Money's constructor promotes private readonly int $cents — the property is actually named cents, not amountCents. new ReflectionProperty($money, 'amountCents') asks Reflection for a property that doesn't exist, and Reflection throws ReflectionException at runtime because there is nothing else it can do — there's no property to hand back.
Why nothing caught this earlier. 'amountCents' is a plain string, not a language-level reference to a property. PHPStan, IDE autocomplete, and PHP itself all check that $obj->cents refers to a real property because cents appears as an identifier the type checker can resolve against the class declaration. A string passed to ReflectionProperty's constructor is just data as far as the type system is concerned — nothing checks it against Money's actual properties until the line executes. This is exactly the "loss of static analysis" cost the article names: Reflection trades compile-time safety for runtime flexibility, and this bug is what that trade costs when it goes wrong.
The deeper design problem. Even with the correct property name ('cents'), this code is wrong. $cents is declared readonly — the language is stating explicitly that a Money object's cents value must never change after construction. setAccessible(true) and ReflectionProperty::setValue() can still write to a readonly property from outside the class in older PHP versions where this trick sometimes worked, but doing so defeats the entire point of declaring it readonly: any other code that received a Money and assumed it was immutable — cached it, used it as an array key, compared it before and after a call — is now wrong. Using Reflection to route around a readonly property is the article's warning about bypassing encapsulation made concrete: the fix isn't a correct property name, it's not using Reflection here at all.
Fix
Money should express "produce an updated value" the way immutable value objects do: return a new instance instead of mutating the existing one.
<?php
declare(strict_types=1);
class Money
{
public function __construct(private readonly int $cents) {}
public function cents(): int
{
return $this->cents;
}
public function withCents(int $newCents): self
{
return new self($newCents);
}
}
$price = new Money(500);
$updated = $price->withCents(750);
echo $price->cents() . ' ' . $updated->cents();
// Prints:
// 500 750
No Reflection, no string-typed property name, and $price is provably unchanged — exactly the guarantee readonly was declared to make. This is the general shape of the fix whenever Reflection is being used to reach past a visibility or immutability modifier in application code: add the method the class should have had, rather than a bypass around the one it does.
Practice: Review Checklist For Reflection Usage
A pull request adds a new use of ReflectionClass/ReflectionMethod/ReflectionProperty somewhere in the codebase — it might be a new autowiring rule in the container, a new attribute-driven feature, or a one-off "quick fix" that reaches into an object with Reflection to solve an immediate problem.
Write a review checklist of at least six checks a reviewer should apply before approving. For each check, name the specific thing to look for, not just the general concern.
Cover at least these angles:
- whether Reflection is the right tool for this problem, versus a design change (a public method, a constructor parameter, an interface);
- performance (is this Reflection call on a hot path or a startup/build-time path?);
- whether it reaches past
private/protected/readonly, and if so, why that's justified; - what happens to static analysis coverage for the code being reflected on;
- whether the class/method/property name being reflected on is a literal string or something dynamic — and where that string comes from;
- test coverage for the specific shape being read (a missing attribute, a missing method, a class that doesn't match the expected shape).
Show solution
-
Is Reflection actually necessary here? If the target class is known at write time — not passed in as a variable class name, not one of an open-ended set the framework must support generically — ask why
->,::, or an interface method wouldn't do the job. Reflection justified by "it was the fastest way to get this working" is the article's warning sign: application code with a known class should use ordinary access operators. -
Where does this run, and how often? A
ReflectionClassscan inside a request-handling loop (re-reflecting the same class on every request) is a performance red flag; the same scan done once at container/framework bootstrap, or ahead of time in a compiled cache, is the pattern the article describes as normal. Check the call site's position, not just that reflection exists somewhere in the codebase. -
Does it reach past a visibility or
readonlymodifier? If the code callssetAccessible(true)on a private property or method, or writes to areadonlyproperty, that must be justified in the PR description (serialization library restoring state, a test asserting on internal state) — not left implicit. Absent a specific justification, this is the article's routing-around-encapsulation smell, and the reviewer should ask for a public method or constructor change instead. -
What happens to static analysis coverage? Any property or method name passed to Reflection as a string (
getProperty('cents'),getMethod('increment')) is invisible to PHPStan/IDE refactoring tools — renaming the real property will not update the string, and nothing will flag the mismatch until it fails at runtime, the way02-diagnose-a-failuredemonstrated. The reviewer should check whether the string is a literal near the class it targets (easier to keep in sync manually) or has drifted far from it (harder, and worth a comment explaining the coupling). -
Where does the class/method/property name being reflected on come from? If it is a hardcoded literal, that's the safe case. If it is a variable — built from configuration, a route parameter, or (worst case) user input — treat it as untrusted input choosing which code runs, per the article's security note, and confirm there's an allow-list or equivalent restriction rather than an open
new ReflectionClass($anyString). -
Is there a test for the shape this code expects, including the missing-shape case? If the code reads an attribute off a method, is there a test for a method that lacks the attribute? If it reads a constructor parameter's type, is there a test for a parameter with no type or a scalar type (mirroring
01-design-the-first-version'sInvalidArgumentExceptioncase)? Reflection code fails differently from ordinary code — usually with a genericReflectionExceptionor a silentnull— so the reviewer should confirm the failure path was deliberately tested, not just the happy path. -
Is the reason for using Reflection documented at the call site? A one-line comment or PR description explaining why this couldn't be a normal method call saves the next maintainer from re-litigating the decision, and gives a future reviewer a way to tell whether the original justification still holds.
A checklist that only confirms "the Reflection code runs and returns the expected value in the happy-path test" should not pass review — it proves the demo works, not that the design choice was sound or that failure modes were considered.