Reflection: Inspecting and Manipulating PHP at Runtime

Understand PHP's Reflection API well enough to inspect classes, methods, properties, parameters, and attributes at runtime; use reflection to build simple dependency injection and metadata-driven tools; and know the real performance cost of using it in hot paths.

Reflection is PHP's built-in ability to examine its own structure while the program is running — to ask a class 'what methods do you have?', a method 'what parameters do you expect and what are they typed as?', or a property 'what attributes were placed on you?'. It is the mechanism underneath almost every framework feature that feels like 'magic': dependency injection containers that auto-wire constructors, ORMs that map database columns to typed properties, routers that read attributes off controller methods, and testing tools that can call private methods. Reflection turns your code's own declarations into data you can query and act on, without needing to already know, at write time, what class or method you'll be dealing with.

The Reflection API surface

The Reflection API is a family of classes under the global namespace, all prefixed Reflection: ReflectionClass describes a class (its name, parent, interfaces, methods, properties, constants, and attributes); ReflectionMethod and ReflectionFunction describe callables; ReflectionProperty describes a single property; ReflectionParameter describes a single function/method parameter, including its declared type and default value; ReflectionAttribute describes a single PHP attribute instance attached to a target; and ReflectionNamedType / ReflectionUnionType describe type declarations themselves (a type can be a simple name, a union like int|string, or nullable).

You almost always start from a ReflectionClass (built from either a class name string or an object instance) and drill down from there: $rc = new ReflectionClass(UserService::class); then $rc->getMethods(), $rc->getProperties(), $rc->getConstructor(), and so on, each returning further Reflection* objects you can inspect recursively.

Reflection is read access by default, but it can also act: ReflectionMethod::invoke() and invokeArgs() call a method (even a private/protected one, after setAccessible(true) — which has been implicit since PHP 8.1, no longer required explicitly), ReflectionProperty::setValue()/getValue() read and write property values directly bypassing normal visibility rules, and ReflectionClass::newInstance()/newInstanceArgs()/newInstanceWithoutConstructor() construct objects — the last one being uniquely useful for scenarios like deserialization or mocking, where you need an object shell without running its constructor logic.

Inspecting methods, properties, and parameters

ReflectionClass::getMethods() returns an array of ReflectionMethod objects; each exposes getName(), isPublic()/isPrivate()/isProtected(), isStatic(), isAbstract(), getReturnType(), and getParameters(). This lets you write generic code that reacts to a class's shape — for example, a debugging dumper that lists every public method a class exposes, or a validator that refuses to proceed if a required method is missing.

ReflectionParameter is where dependency injection containers get their real power: getType() returns the declared type (or null if untyped), getName() the parameter name, isOptional()/isDefaultValueAvailable() and getDefaultValue() tell you whether — and what — to fall back to, and allowsNull() tells you whether a typed parameter also accepts null. Reading a constructor's parameter types is exactly how a container decides what to construct and pass in for each argument.

ReflectionProperty mirrors this for properties: getType(), isReadOnly() (PHP 8.1+), isPromoted() (whether it came from constructor property promotion), and getDefaultValue(). Combined, ReflectionClass gives you a complete, queryable map of a class's public contract and internal shape without needing any documentation beyond the code itself.

Attributes as structured, reflectable metadata

Attributes (#[...], introduced in PHP 8.0) are structured metadata you attach to a class, method, property, parameter, or constant, and reflection is the only way to read them back — they have no runtime effect on their own. ReflectionClass::getAttributes(), ReflectionMethod::getAttributes(), and similar methods return an array of ReflectionAttribute objects; each has getName() (the attribute class name), getArguments() (the raw constructor arguments as passed), and newInstance(), which actually instantiates the attribute class with those arguments, letting you treat it as a normal typed object.

This is how frameworks implement declarative behavior like #[Route('/users/{id}')] on a controller method: the router's bootstrap code reflects over every controller class, reads #[Route] attributes off each method via getAttributes(Route::class), calls newInstance() to get a real Route object, and reads its path/method properties to build the routing table — all before a single HTTP request has arrived.

Attributes can restrict where they may be applied using the #[Attribute] attribute's flags (Attribute::TARGET_CLASS, TARGET_METHOD, TARGET_PROPERTY, TARGET_PARAMETER, TARGET_CLASS_CONSTANT, or TARGET_ALL), and IS_REPEATABLE to allow the same attribute more than once on the same target — reflection respects these constraints when you attempt to read attributes back.

Reflection-driven dependency injection

A minimal autowiring container works by reflecting a class's constructor, walking its parameters, and recursively resolving each one: if a parameter's type is itself a class (not a scalar like int or string), recursively build that class the same way; if it has a default value, use it when no explicit binding exists; if it's a scalar with no default and no explicit binding, the container cannot autowire it and should throw a clear error rather than guessing.

This is powerful because it eliminates a large class of boilerplate — you stop manually writing new Foo(new Bar(new Baz())) chains — but it is also implicit magic: the wiring is derived from type declarations rather than written out, so mistakes (a missing binding, an interface with multiple implementations and no way to disambiguate) surface as runtime errors instead of compile-time or even IDE-visible ones. Most production DI containers (Symfony's, Laravel's) combine autowiring for the easy cases with explicit configuration for anything ambiguous.

Performance trade-offs

Reflection is measurably slower than direct code: instantiating ReflectionClass, walking methods/properties, and especially invokeArgs()/dynamic property access bypass PHP's normal opcode-level optimizations and involve extra internal bookkeeping. In a single request handling one route dispatch, this cost is invisible — a handful of microseconds. In a tight loop calling a reflected method thousands of times per request, it becomes measurable and sometimes dominant.

The standard mitigation used by every serious framework is to reflect once and cache the result, not to reflect on every request. Symfony and Laravel both compile reflection-derived metadata (route tables, service definitions, validation rules) into a cached PHP file or array during a 'warmup' step, so production requests read from that cache instead of re-running reflection. The rule of thumb: reflection is a build-time or bootstrap-time tool, not a per-request-hot-path tool — use it to generate or discover configuration once, then reuse the derived result cheaply.

A second, subtler cost is opacity: code driven by reflection (attributes, autowiring) is harder to trace with 'find usages' or step-through debugging than code with explicit, written-out calls, because the connection between a class and its consumer only exists at runtime. This is a genuine maintainability trade-off, not just a performance one — reflection-heavy code reads shorter but debugs harder.

Core Reflection API calls

setAccessible(true) has been unnecessary since PHP 8.1 (private/protected members are always accessible via reflection now), but calling it is harmless and keeps code compatible with older PHP. newInstanceWithoutConstructor() is the standard trick used by (de)serialization libraries and mocking frameworks that need a real object of the right class without running possibly-expensive or side-effecting constructor logic.

// Building a ReflectionClass
$rc = new ReflectionClass(SomeClass::class);   // from a class name
$rc = new ReflectionObject($someInstance);      // from an instance

// Class-level introspection
$rc->getName();
$rc->getParentClass();
$rc->getInterfaceNames();
$rc->isAbstract();
$rc->isInterface();

// Members
$rc->getMethods();          // ReflectionMethod[]
$rc->getProperties();       // ReflectionProperty[]
$rc->getConstructor();      // ReflectionMethod|null
$rc->hasMethod('save');

// Attributes (PHP 8+)
foreach ($rc->getAttributes(Route::class) as $attr) {
    $route = $attr->newInstance(); // Route object, args already applied
}

// Invoking privately-scoped members
$method = $rc->getMethod('secretCalculation');
$method->setAccessible(true);       // no-op on 8.1+, harmless to keep
$result = $method->invoke($instance, $arg1, $arg2);

// Constructing without running the constructor
$obj = $rc->newInstanceWithoutConstructor();

Code Examples

Reflecting Product lets us print every method name along with each parameter's declared type and name, purely by reading the class's own declarations — no hardcoded knowledge of Product is baked into this printing code, so it works unchanged for any class passed in. Expected result: __construct(string $name, float $price) applyDiscount(float $percent)

This is the core mechanism behind every attribute-based router: walk every method on a controller class, read its #[Route] attributes (there may be zero, one, or more per method), instantiate each one to get a typed Route object, and use its method/path to build a routing table entry pointing at that method. Expected result: GET /users -> index POST /users -> store

This minimal container recursively autowires any class graph made of typed, class-valued constructor parameters: it reflects the constructor, and for each parameter whose type is itself a class (not a scalar), it recursively resolves that class the same way. Scalar parameters without a default become hard errors instead of silent guesses.

if ($type instanceof ReflectionNamedType && !$type->isBuiltin()) { $args[] = $this->make($type->getName()); }

class Product
{
    public function __construct(
        public readonly string $name,
        private float $price,
    ) {}

    public function applyDiscount(float $percent): float
    {
        return $this->price * (1 - $percent / 100);
    }
}

$rc = new ReflectionClass(Product::class);

echo $rc->getName() . "\n";                       // Product

foreach ($rc->getMethods() as $method) {
    echo $method->getName() . "(";
    $params = array_map(
        fn (ReflectionParameter $p) => (string) $p->getType() . ' $' . $p->getName(),
        $method->getParameters(),
    );
    echo implode(', ', $params) . ")\n";
}
#[Attribute(Attribute::TARGET_METHOD)]
class Route
{
    public function __construct(
        public string $path,
        public string $method = 'GET',
    ) {}
}

class UserController
{
    #[Route('/users', method: 'GET')]
    public function index(): array { return []; }

    #[Route('/users', method: 'POST')]
    public function store(): array { return []; }
}

$rc = new ReflectionClass(UserController::class);

foreach ($rc->getMethods() as $method) {
    foreach ($method->getAttributes(Route::class) as $attr) {
        /** @var Route $route */
        $route = $attr->newInstance();
        echo "{$route->method} {$route->path} -> {$method->getName()}\n";
    }
}
final class Container
{
    /** @var array<string, callable> */
    private array $bindings = [];

    public function bind(string $abstract, callable $factory): void
    {
        $this->bindings[$abstract] = $factory;
    }

    public function make(string $class): object
    {
        if (isset($this->bindings[$class])) {
            return ($this->bindings[$class])($this);
        }

        $rc = new ReflectionClass($class);
        $constructor = $rc->getConstructor();

        if ($constructor === null) {
            return $rc->newInstance();
        }

        $args = [];
        foreach ($constructor->getParameters() as $param) {
            $type = $param->getType();

            if ($type instanceof ReflectionNamedType && !$type->isBuiltin()) {
                $args[] = $this->make($type->getName()); // recursive resolution
                continue;
            }

            if ($param->isDefaultValueAvailable()) {
                $args[] = $param->getDefaultValue();
                continue;
            }

            throw new RuntimeException(
                "Cannot autowire scalar parameter \${$param->getName()} of {$class}"
            );
        }

        retur
…

Reflection resolving a constructor dependency graph

Autowiring recursively reflects each constructor's parameter types, resolving from the leaves (classes with no further class dependencies) back up to the originally requested class.

make(UserService::class)
        |
        v
  reflect constructor -> param: UserRepository $repo
        |
        v
  make(UserRepository::class)
        |
        v
  reflect constructor -> param: Logger $logger
        |
        v
  make(Logger::class) -> no constructor -> new Logger()
        |
        v
  new UserRepository(logger)
        |
        v
  new UserService(repo)

Important Rules

Start from ReflectionClass (or ReflectionObject) and drill into getMethods()/getProperties()/getConstructor() to inspect a class's shape.

Attributes are inert metadata by themselves — only reflection (getAttributes() + newInstance()) turns them into usable objects your code can act on.

Reflection can bypass visibility (private/protected members) and even skip constructors (newInstanceWithoutConstructor()) — powerful, but only appropriate for framework-level tooling, not everyday business logic.

Reflection is meaningfully slower than direct calls — reflect once at bootstrap/build time and cache the derived metadata, never re-reflect inside a per-request hot loop.

Reflection-driven code (autowiring, attribute dispatch) trades explicitness for brevity — it is harder to trace statically, so use it where the framework/tooling value clearly outweighs that opacity.

Where this shows up

Every major PHP framework leans on reflection under the hood: Symfony's and Laravel's service containers autowire constructors; PHPUnit uses reflection to discover test methods (by name convention or #[Test] attribute) and to call private methods in white-box tests; Doctrine ORM reflects entity properties to map database rows onto objects without requiring public setters; and API frameworks like Symfony's API Platform or Laravel's route model binding read attributes/docblocks to generate OpenAPI schemas automatically from your existing classes.

// Reflecting the same class on every single request function handleRequest(string $controllerClass) { $rc = new ReflectionClass($controllerClass); foreach ($rc->getMethods() as $m) { foreach ($m->getAttributes(Route::class) as $a) { /* ... */ } } }

// Reflect once, cache the derived routing table, reuse it function buildRouteCache(array $controllerClasses): array { /* reflect once, return array */ } $routes = $cachedRoutesArray ?? buildRouteCache($controllerClasses); // load from cache file in prod

Re-running reflection on every request repeats the same expensive introspection work for data that never changes between requests. Reflect once (at deploy/bootstrap time), serialize the result (a plain array or a generated PHP file), and load that cheap, plain data on every request instead.

[multiple-choice] What is the primary purpose of PHP attributes combined with reflection? A. Attributes automatically change how a method executes without any extra code B. Attributes attach structured, inert metadata to declarations, which reflection reads back and turns into usable objects your code decides how to act on C. Attributes replace the need for typed parameters D. Attributes are only usable on classes, never on methods or properties

B. Attributes attach structured, inert metadata to declarations, which reflection reads back and turns into usable objects your code decides how to act on — Attributes have no runtime behavior on their own — they are structured metadata. Only your own reflection-based code (getAttributes() + newInstance()) reads them and decides what to do, which is exactly how attribute-driven routers and validators are built.

[multiple-choice] What is the recommended way to handle reflection's performance cost in a production framework? A. Avoid reflection entirely and hand-write every dependency wiring B. Reflect fresh on every request since the cost is negligible at any scale C. Reflect once (at bootstrap/build time), cache the derived metadata, and reuse that cheap cached data on every request D. Only use reflection in local development, never in production

C. Reflect once (at bootstrap/build time), cache the derived metadata, and reuse that cheap cached data on every request — Reflection's cost is fine as a one-time or bootstrap-time operation but adds up in per-request hot paths. Frameworks like Symfony and Laravel compile reflection-derived metadata into a cache so production requests avoid re-reflecting.

[predict-output] Given $rc = new ReflectionClass(Foo::class); $rc->newInstanceWithoutConstructor(); — what happens to Foo's constructor? A. It is called with all null arguments B. It is never called at all — an instance of Foo is created without running __construct C. PHP throws a fatal error because constructors cannot be skipped D. It is called only if Foo has a default constructor

B. It is never called at all — an instance of Foo is created without running __construct — newInstanceWithoutConstructor() creates a real object of the target class's type while deliberately skipping the constructor entirely — useful for deserialization or mocking scenarios where constructor side effects are undesired.

Write a function describeClass(string $class): array that reflects the given class and returns an associative array mapping each public method name to an array of its parameter names (in order). Skip the constructor. Test it against a small class of your own with at least two public methods.

function describeClass(string $class): array { $rc = new ReflectionClass($class); $result = []; foreach ($rc->getMethods(ReflectionMethod::IS_PUBLIC) as $method) { if ($method->getName() === '__construct') continue; $result[$method->getName()] = array_map( fn (ReflectionParameter $p) => $p->getName(), $method->getParameters(), ); } return $result; }

Use ReflectionClass::getMethods(ReflectionMethod::IS_PUBLIC), filter out __construct, and for each remaining method map getParameters() through getName().