Reflection — Advanced Metadata and Framework Use

Go beyond basic class/method reflection to reflect enums and their cases, class constants and their modifiers, union/intersection/nullable types, attributes in depth, and property/method modifiers like readonly, abstract, and final — then apply all of it to two realistic scenarios (ORM metadata extraction and CLI argument binding) while understanding how to cache reflection results correctly.

The first Reflection lesson covered the fundamentals: ReflectionClass, walking methods/properties/parameters, reading attributes, and using all of it to build a small dependency-injection container. That's enough to understand what reflection is and how frameworks lean on it — but real framework internals reflect a wider surface than that: enums, class constants and their modifiers, the more elaborate type shapes PHP 8's type system allows, and attributes used for more than just routing. This lesson covers that wider surface, applies it to two concrete scenarios (extracting ORM column metadata and binding CLI arguments), and closes with the caching discipline every production framework actually uses to keep reflection-driven code fast.

Reflecting enums: ReflectionEnum and ReflectionEnumBackedCase

PHP 8.1 enums get their own reflection classes. ReflectionEnum (which extends ReflectionClass) adds isBacked(): bool, getBackingType(): ?ReflectionNamedType, and getCases(), which returns each case as a ReflectionEnumUnitCase (for a pure enum) or a ReflectionEnumBackedCase (for a backed one). Each backed case's reflection object exposes getName() and getBackingValue(), giving you the case's declared scalar value without needing to instantiate the case or compare it directly.

This matters for writing genuinely generic tooling: a function that turns any backed enum into a value=>label dropdown, or a validator that checks an incoming scalar against an enum's valid backing values, can operate on any enum class passed to it — it never has to hardcode which enum, or how many cases it has, because getCases() enumerates them at runtime.

• ReflectionEnum extends ReflectionClass, adding isBacked()/getBackingType()/getCase()/getCases(). • getCases() returns ReflectionEnumUnitCase (unbacked) or ReflectionEnumBackedCase (backed) objects. • ReflectionEnumBackedCase::getBackingValue() reads a case's declared scalar value generically.

Reflecting class constants: ReflectionClassConstant

Class constants (const X = ...; inside a class, enum, or interface) have their own reflector, ReflectionClassConstant, retrieved via ReflectionClass::getReflectionConstants() (all of them) or getReflectionConstant($name) (a single one, or false) — note this is distinct from getConstants()/getConstant(), which return raw values, not reflector objects. ReflectionClassConstant exposes getName(), getValue(), isPublic()/isProtected()/isPrivate(), isFinal() (class constants can be declared final since PHP 8.1, preventing subclasses from overriding them), and, as of PHP 8.3, getType() for typed class constants and isEnumCase() to tell whether the 'constant' being examined is actually an enum case under the hood.

There is no dedicated Reflection* class for plain global constants (ones declared with a top-level const or via define()) — inspect those with PHP's ordinary defined() and constant() functions instead, since they aren't attached to any class the Reflection API's object model is built around.

Reflecting types: union, intersection, and nullable

getType() (on a ReflectionParameter, ReflectionProperty, or a function/method's return type) returns one of three shapes, all implementing a common ReflectionType base: ReflectionNamedType for a single named type — possibly nullable, checkable via allowsNull() — ReflectionUnionType (PHP 8.0+) for A|B|C, whose getTypes() returns each branch as its own ReflectionNamedType, and ReflectionIntersectionType (PHP 8.1+) for A&B, exposing getTypes() the same way.

Generic reflection code has to branch on which concrete shape it received — typically with instanceof — rather than assuming getType() always returns a single ReflectionNamedType; calling ->getName() on a value that turned out to be a ReflectionUnionType or ReflectionIntersectionType fails, because neither of those classes has a getName() method at all. allowsNull() works uniformly across a plain nullable type (?Foo), a union that includes null (Foo|null), and the standalone null type — always ask the type itself rather than string-matching against 'null' in getName()'s output.

Reflecting attributes in depth

Beyond newInstance(), ReflectionAttribute exposes getArguments(), returning the raw, unresolved argument list exactly as written in the source (positional arguments by numeric key, named arguments by string key) — useful for inspecting an attribute's configuration without triggering its constructor, for example when that constructor performs expensive validation you don't want run during a bulk metadata scan. getTarget() reports which kind of declaration the attribute is attached to, via Attribute::TARGET_* bit flags, letting one reflection pass distinguish a #[Cache] attribute on a method from the same attribute class used on a property.

getAttributes() accepts an optional flags argument, ReflectionAttribute::IS_INSTANCEOF, to match subclasses of a given attribute class too, not only an exact match — useful when several concrete attributes (several #[XxxValidator] classes) all extend one abstract #[Validator] base and you want to collect all of them generically. Attributes marked Attribute::IS_REPEATABLE on their own class definition can be applied more than once to the same target, and getAttributes() simply returns more than one ReflectionAttribute for that target in that case, in source order.

Reflecting modifiers: readonly, abstract, final

ReflectionProperty::isReadOnly() reports whether a property was declared readonly (PHP 8.1+, per-property); PHP 8.3 also allows an entire class to be declared readonly at once (applying to every typed property in it), which ReflectionClass::isReadOnly() reports for that class-level form. ReflectionClass and ReflectionMethod both expose isAbstract() and isFinal(), and — as covered above — individual class constants can be isFinal() too since PHP 8.1. Modifiers you would otherwise have to parse out of source code by hand are all just boolean method calls on the relevant Reflection* object.

This is what lets structural, static-analysis-adjacent tooling — an architecture linter, an 'immutability violation' checker, a codemod that finds every non-final public class in a codebase — answer questions about a whole codebase's shape without parsing PHP source text itself; reflection hands back the already-parsed, already-typed structure PHP's own engine computed while loading the class.

Dynamic object creation via reflection, revisited

newInstanceArgs(array $args) remains the standard hydration primitive: given a plain array of constructor arguments — often built by walking ReflectionParameter objects and mapping raw input data onto them — it constructs a fully-typed object through its real constructor. newInstanceWithoutConstructor() skips the constructor entirely; ReflectionProperty::setValue($object, $value) can then assign each property one at a time, including a readonly property's very first (and only) write, which reflection is explicitly allowed to perform even from outside the declaring class, exactly to support this hydration use case.

This combination — reflect a target class's typed properties, match them against a data source's keys by name (and validate by type), and write them in with setValue() — is how most PHP ORMs and serializers implement hydration without requiring every entity class to hand-write a fromArray()/toArray() pair, and without giving up the safety of readonly properties for normal application code that isn't hydrating from storage.

Applied: ORM metadata extraction and CLI argument binding

ORM metadata extraction: reflect an entity class once, read a #[Column] attribute off each typed property (its declared name, and nullable-ness derived from that property's own ReflectionNamedType::allowsNull()), and build a column map the ORM's query builder and hydrator both consume — the entity class stays plain PHP, attributes are the only extra syntax involved, and the ORM never needs a separate mapping configuration file kept in sync by hand.

CLI argument binding: reflect a command handler's method parameters, match each ReflectionParameter's name (or an attribute like #[Option('--verbose')] placed on it) against the raw argv tokens, cast the matched string value to the parameter's declared type using getType(), and fall back to getDefaultValue() for anything not passed on the command line. This is the mechanism behind modern CLI frameworks that let a developer write a plain method signature like handle(string $name, bool $verbose = false) and have the framework populate it automatically from the command line, instead of manually parsing $argv.

Performance: caching reflection results correctly

Constructing a ReflectionClass and reading its shape is cheap in absolute terms for a single class — the real cost accumulates when a full walk (methods, parameters, attributes, and newInstance() on each) is repeated thousands of times per request, across every route, entity, or command in an application. The standard pattern is to build a small cache — a static array keyed by class name, or a PSR-6/PSR-16 cache-backed one — the first time a class's metadata is needed, and to return that cached data on every subsequent lookup within the same process instead of re-reflecting.

In production, that cache is often additionally warmed once at deploy time and persisted (a generated PHP file, an opcache-friendly array literal) so not even the first request of a freshly started process pays the reflection cost. A common mistake is caching the ReflectionClass object itself in a persistent, cross-process cache like APCu or Redis — Reflection* objects don't serialize in a portable, useful way across processes; cache the plain data extracted FROM reflection (arrays, DTOs), never the reflection objects themselves.

Code Examples

enumToOptions() works for any backed enum passed to it — it never mentions OrderStatus by name — because ReflectionEnum::getCases() returns each case as a ReflectionEnumBackedCase object whose getBackingValue()/getName() generically expose the scalar value and case name, exactly what a form-builder or API-docs generator needs to turn an arbitrary enum into a value=>label list. Expected result: Array ( [pending] => Pending [shipped] => Shipped [delivered] => Delivered )

$options[$case->getBackingValue()] = $case->getName();

describeParamType() branches on which concrete ReflectionType subclass getType() actually returned — ReflectionUnionType for int|string, a nullable ReflectionNamedType for ?string, and ReflectionIntersectionType for the Loggable&Cacheable constraint — so one small function can generically describe any parameter's type shape without special-casing each declaration style. Expected result: id: union: string|int note: named: string (nullable) report: intersection: Loggable&Cacheable

$type instanceof ReflectionIntersectionType => 'intersection: '. implode('&',...) hydrate() bypasses User's constructor entirely with newInstanceWithoutConstructor() and instead writes each property directly via reflection, using each property's own #[Column] attribute to find the matching source key — reflection's setValue() is explicitly permitted to perform this single initial write to an uninitialized readonly property, which is exactly what lets ORMs populate readonly entity properties from a database row without a public setter ever existing. Expected result: 7: Grace Hopper

$property->setValue($instance, $row[$column->name]);

Common mistake: Trying $user->name = 'new value'; on an already-hydrated instance afterward: once a readonly property has been initialized — even via reflection — any further write, from inside or outside the class, throws Error: Cannot modify readonly property.

The first call to columnMapFor() does the real reflection work and stores the resulting plain array — not any Reflection* object — in a static cache keyed by class name; every subsequent call for the same class in the same process returns that array directly, so a given class only ever gets reflected once per process no matter how many times its metadata is needed. Expected result: Array ( [id] => invoice_id [total] => total_cents) Array ( [id] => invoice_id [total] => total_cents) if (isset(self::$cache[$class])) { return self::$cache[$class]; }

enum OrderStatus: string
{
    case Pending = 'pending';
    case Shipped = 'shipped';
    case Delivered = 'delivered';
}

function enumToOptions(string $enumClass): array
{
    $reflection = new ReflectionEnum($enumClass);

    if (!$reflection->isBacked()) {
        throw new InvalidArgumentException("{$enumClass} must be a backed enum");
    }

    $options = [];
    foreach ($reflection->getCases() as $case) {
        /** @var ReflectionEnumBackedCase $case */
        $options[$case->getBackingValue()] = $case->getName();
    }

    return $options;
}

print_r(enumToOptions(OrderStatus::class));
interface Loggable {}
interface Cacheable {}

class Report implements Loggable, Cacheable {}

function describeParamType(ReflectionParameter $param): string
{
    $type = $param->getType();

    return match (true) {
        $type instanceof ReflectionUnionType =>
            'union: ' . implode('|', array_map(fn ($t) => (string) $t, $type->getTypes())),
        $type instanceof ReflectionIntersectionType =>
            'intersection: ' . implode('&', array_map(fn ($t) => (string) $t, $type->getTypes())),
        $type instanceof ReflectionNamedType =>
            'named: ' . $type->getName() . ($type->allowsNull() ? ' (nullable)' : ''),
        default => 'untyped',
    };
}

function handleReport(int|string $id, ?string $note, Loggable&Cacheable $report): void {}

$function = new ReflectionFunction('handleReport');
foreach ($function->getParameters() as $param) {
    echo $param->getName() . ': ' . describeParamType($param) . PHP_EOL;
}
#[Attribute(Attribute::TARGET_PROPERTY)]
class Column
{
    public function __construct(public string $name) {}
}

final class User
{
    public function __construct(
        #[Column('id')] public readonly int $id,
        #[Column('full_name')] public readonly string $name,
    ) {}
}

function hydrate(string $class, array $row): object
{
    $reflection = new ReflectionClass($class);
    $instance = $reflection->newInstanceWithoutConstructor();

    foreach ($reflection->getProperties() as $property) {
        $attrs = $property->getAttributes(Column::class);
        if ($attrs === []) {
            continue;
        }

        $column = $attrs[0]->newInstance();
        $property->setValue($instance, $row[$column->name]);
    }

    return $instance;
}

$user = hydrate(User::class, ['id' => 7, 'full_name' => 'Grace Hopper']);
echo "{$user->id}: {$user->name}" . PHP_EOL;
#[Attribute(Attribute::TARGET_PROPERTY)]
class Column
{
    public function __construct(public string $name) {}
}

final class Invoice
{
    public function __construct(
        #[Column('invoice_id')] public readonly int $id,
        #[Column('total_cents')] public readonly int $total,
    ) {}
}

final class ReflectionMetadataCache
{
    /** @var array<string, array<string, string>> */
    private static array $cache = [];

    public static function columnMapFor(string $class): array
    {
        if (isset(self::$cache[$class])) {
            return self::$cache[$class];
        }

        $map = [];
        $reflection = new ReflectionClass($class);

        foreach ($reflection->getProperties() as $property) {
            foreach ($property->getAttributes(Column::class) as $attr) {
                $map[$property->getName()] = $attr->newInstance()->name;
            }
        }

        return self::$cache[$class] = $map;
    }
}

print_r(ReflectionMetadataCache::columnMapFor(Invoice::class));
print_r(ReflectionMetadataCache::columnMapFor(Invoice::class));

From source code to framework metadata

Reflection's job stops at producing metadata — turning that metadata into behavior (running a query, dispatching a command) is ordinary application/framework code, run against a CACHED copy of the metadata in production, not re-reflected on every request.

PHP Source
  (class, method, property, #[Attribute] declarations)
        |
        v
ReflectionClass / ReflectionMethod / ReflectionProperty / ReflectionAttribute
  (the Reflection API reads the already-parsed structure)
        |
        v
Metadata
  (plain arrays / DTOs: column maps, route tables, CLI option bindings)
        |
        v
Framework / Tooling
  (ORM query builder, router, CLI dispatcher, DI container, linter)

Important Rules

Use ReflectionEnum/ReflectionEnumBackedCase instead of hardcoding a switch over an enum's cases when writing generic tooling.

Always branch on ReflectionNamedType/ReflectionUnionType/ReflectionIntersectionType (via instanceof) rather than assuming getType() always returns a single named type.

Prefer getArguments() over newInstance() when you only need an attribute's raw configuration and want to avoid running its constructor.

Use newInstanceWithoutConstructor() + setValue() for hydration scenarios where you deliberately want to skip constructor validation or side effects.

Cache the plain data reflection produces, never the Reflection* objects themselves, across requests or processes.

Reflect a given class once per process (or once at build/deploy time); never re-reflect the same class inside a per-request hot loop.

Where this shows up

Doctrine ORM's attribute-based mapping driver reflects entity classes once (reading #[Column], #[Id], and similar attributes) and caches the derived metadata through its metadata factory rather than re-reflecting on every request; modern CLI frameworks bind command method parameters straight from argv using the same parameter-and-attribute reflection shown here; and static-analysis-adjacent tooling (custom architecture linters, codemods) reflects modifiers like readonly/abstract/final to flag structural violations across an entire codebase without parsing PHP source text by hand.

apcu_store('meta:'. $class, new ReflectionClass($class)); // caching the reflector object itself across requests apcu_store('meta:'. $class, extractPlainMetadata(new ReflectionClass($class))); // cache the plain array/DTO extracted FROM it

Reflection* objects are not meant to be serialized and reused across processes or requests in a persistent cache like APCu or Redis. Extract the plain data you actually need (an array or a simple DTO) from the reflector once, and cache that instead — it's what every serious framework's metadata cache actually stores.

$type = $param->getType(); echo $type->getName(); // assumes a single named type; fatal error if $param is declared int|string

$type = $param->getType(); if ($type instanceof ReflectionNamedType) { echo $type->getName(); } else { /* handle ReflectionUnionType / ReflectionIntersectionType explicitly */ } getType() can return a ReflectionNamedType, a ReflectionUnionType, or a ReflectionIntersectionType depending on how the parameter was declared, and only ReflectionNamedType has a getName() method — calling ->getName() unconditionally throws a fatal Error the moment it encounters a union- or intersection-typed parameter.

What does ReflectionEnumBackedCase::getBackingValue() return? A. The case's name as a string, regardless of backing type B. The case's declared scalar backing value (its int or string), without needing to instantiate or compare the case directly C. The number of cases the enum has in total D. Nothing — this method does not exist

B. The case's declared scalar backing value (its int or string), without needing to instantiate or compare the case directly — getBackingValue() reads a backed enum case's declared scalar value directly from its reflection, which is exactly what lets generic tooling (like a dropdown builder) work with any backed enum without hardcoding which one.

Why does calling ->getName() unconditionally on the result of getType() risk a fatal error? A. getType() always returns null for untyped parameters, and null has no methods B. getType() can return a ReflectionUnionType or ReflectionIntersectionType, neither of which has a getName() method — only ReflectionNamedType does C. getName() was removed in PHP 8.2 D. It never does; this is always safe

B. getType() can return a ReflectionUnionType or ReflectionIntersectionType, neither of which has a getName() method — only ReflectionNamedType does — getType() returns different concrete classes depending on how a type was declared. Only ReflectionNamedType exposes getName(); calling it on a ReflectionUnionType or ReflectionIntersectionType instance fails, so generic code must check with instanceof first.

When would you prefer ReflectionAttribute::getArguments() over calling newInstance()? A. Never — newInstance() is always strictly better B. When you only need the attribute's raw, unresolved configuration and want to avoid running its constructor (e.g., to skip expensive validation during a bulk scan) C. getArguments() and newInstance() return identical results, so it never matters D. Only when the attribute has no constructor at all

B. When you only need the attribute's raw, unresolved configuration and want to avoid running its constructor (e.g., to skip expensive validation during a bulk scan) — getArguments() exposes the attribute's raw constructor arguments exactly as written in source, without instantiating it — useful when you want the configuration without triggering side effects or expensive validation the attribute's own constructor performs.

Why shouldn't a ReflectionClass object itself be stored in a persistent cross-process cache like APCu or Redis? A. It is illegal to construct more than one ReflectionClass per request B. Reflection* objects are not meant to serialize in a portable, useful way across processes — the plain data extracted from them should be cached instead C. ReflectionClass objects expire automatically after one request D. PHP forbids storing objects in APCu entirely

B. Reflection* objects are not meant to serialize in a portable, useful way across processes — the plain data extracted from them should be cached instead — The standard, correct caching pattern is to reflect once, extract the plain metadata you need into arrays or simple DTOs, and cache that plain data — never the Reflection* objects themselves, which aren't designed to be reused that way across processes.

How can newInstanceWithoutConstructor() combined with ReflectionProperty::setValue() correctly populate a readonly property? A. It cannot — readonly properties can never be set outside their declaring constructor under any circumstances B. setValue() is explicitly allowed to perform a readonly property's first (and only) initialization, even from outside the declaring class, which is exactly what supports hydration scenarios like ORMs C. readonly is ignored entirely when using reflection D. It works only if the property is also declared public

B. setValue() is explicitly allowed to perform a readonly property's first (and only) initialization, even from outside the declaring class, which is exactly what supports hydration scenarios like ORMs — Reflection's setValue() is permitted to perform the single initializing write to an uninitialized readonly property, even from outside the declaring scope — this is precisely the mechanism ORMs and serializers rely on to hydrate readonly entity properties from a database row without a public setter, while any subsequent write still throws as normal.

Write a function isImmutable(string $class): bool that uses reflection to return true only if the class itself is declared readonly, or every one of its own typed properties is individually readonly (ignore untyped properties, since readonly requires a type).

function isImmutable(string $class): bool { $reflection = new ReflectionClass($class); if ($reflection->isReadOnly()) { return true; } foreach ($reflection->getProperties() as $property) { if ($property->getType() === null) { continue; } if (!$property->isReadOnly()) { return false; } } return true; }

Start with ReflectionClass::isReadOnly() as a fast-path true; otherwise walk getProperties(), skip any property whose getType() is null, and require every remaining one to return true from isReadOnly().