skip to content

Code Metadata & Introspection

PHP code that describes or inspects other code: #[...] attributes attached to declarations and the Reflection API that reads classes, types and parameters. Frameworks build routing and DI on both.

part ofPHPoverview, primer and where to startread it →
on this pageshow

explore

questions

11

In PHP, how do you declare a custom attribute class such as #[Route] and read it back from controller methods at run time?

level: middleimportance: must knowfreq 45%

answer

  1. an ordinary class marked #[Attribute]
  2. constructor receives the arguments
  3. getAttributes() returns ReflectionAttribute objects
  4. getArguments() vs newInstance()
  5. ReflectionAttribute::IS_INSTANCEOF for subclasses

basics

~10 s

Write an ordinary class marked #[Attribute] whose constructor takes the attribute's arguments, put #[Route('/users')] on a method, then call getAttributes(Route::class) on its ReflectionMethod and newInstance() on each ReflectionAttribute returned.

solid answer

~40 s

An attribute class is a normal, non-abstract class carrying `#[Attribute]`; its constructor parameters define the arguments, and readonly promoted properties make it a small value object. You attach it with `#[Route('/users', methods: ['GET'])]`. To read it, reflect the declaration and call `getAttributes(?string $name = null, int $flags = 0)`, which exists on `ReflectionClass`, `ReflectionFunctionAbstract`, `ReflectionProperty`, `ReflectionClassConstant`, `ReflectionParameter` and `ReflectionConstant`. It returns an array of `ReflectionAttribute`, not attribute objects: `getName()` gives the resolved class name, `getArguments()` the raw arguments, and `newInstance()` autoloads the class, validates target and repetition, and calls the constructor. A name filter matches exactly; add `ReflectionAttribute::IS_INSTANCEOF` to include subclasses and implementations.

code

php · 25 lines
php
<?php
declare(strict_types=1);

#[Attribute(Attribute::TARGET_METHOD)]
final class Route
{
    public function __construct(
        public readonly string $path,
        public readonly array $methods = ['GET'],
    ) {}
}

final class UserController
{
    #[Route('/users', methods: ['GET', 'HEAD'])]
    public function list(): string { return 'users'; }
}

foreach ((new ReflectionClass(UserController::class))->getMethods() as $method) {
    foreach ($method->getAttributes(Route::class) as $attr) {
        $route = $attr->newInstance();
        echo implode('|', $route->methods), ' ', $route->path, ' -> ', $method->getName(), PHP_EOL;
    }
}
// GET|HEAD /users -> list

go deeper

for a junior

Remember the three pieces: a class marked #[Attribute], the #[Name(args)] usage, and getAttributes() plus newInstance() to read it.

for a middle

Explain the ReflectionAttribute stage in between, what getArguments() returns for positional and named arguments, and when IS_INSTANCEOF is needed.

for a senior

Design attribute classes as validated readonly value objects, decide where newInstance() errors should surface, and cache instances instead of rebuilding them per call.

for a principal

Decide which metadata belongs in attributes at all versus explicit configuration, since attribute-driven wiring hides behaviour from the call site and from code review.

## Declaring the attribute class A PHP **attribute** is backed by an ordinary class. What makes it an attribute class is the built-in `#[Attribute]` marker on the class itself: - the class must be concrete — since PHP 8.5, `#[Attribute]` on an abstract class, interface, trait or enum is a compile-time error; - its **constructor parameters** define what arguments the attribute accepts, including defaults, types and named arguments; - readonly promoted properties (`public function __construct(public readonly string $path)`) are the usual shape, because an attribute instance is a value, not a service; - an optional bitmask on the marker, such as `#[Attribute(Attribute::TARGET_METHOD)]`, restricts where it may be placed. The marker is imported like any class: `use Attribute;` or written as `#[\Attribute]`. ## Attaching it On the controller method you write `#[Route('/users', methods: ['GET'])]`. Several attributes can be grouped as `#[Route('/users'), RequiresRole('admin')]` or written on separate lines. Arguments may be positional or named and must be constant expressions. ## Reading it back Reading is a two-stage process through the Reflection API: 1. **Reflect the declaration** — `new ReflectionMethod(UserController::class, 'list')`, or iterate `(new ReflectionClass($class))->getMethods()`. 2. **List the attributes** — `getAttributes()` returns an array of `ReflectionAttribute` in the order they were written. Nothing is autoloaded or constructed at this stage. 3. **Inspect or instantiate** each one. | `ReflectionAttribute` method | Returns | |---|---| | `getName()` | the fully qualified class name after `use` resolution | | `getArguments()` | the raw arguments: integer keys for positional, string keys for named | | `getTarget()` | an `Attribute::TARGET_*` value for where it was found | | `isRepeated()` | whether the same attribute appears more than once on this declaration | | `newInstance()` | an object of the attribute class, built by calling its constructor | `newInstance()` is where all the checks happen: the class is autoloaded (`Attribute class "X" not found` if it cannot be), it must carry `#[Attribute]`, the target and repetition flags are enforced, and the arguments are passed to the constructor. That constructor call respects the `declare(strict_types=1)` of the file where the attribute was **written**, not of the file calling `newInstance()`. Each call builds a fresh object, so cache the instances if you read them often. ## Where getAttributes() is available Every kind of declaration that can carry an attribute has a reflector with the same `getAttributes(?string $name = null, int $flags = 0)` method: - `ReflectionClass` (and `ReflectionObject`, `ReflectionEnum`) for classes, interfaces, traits and enums; - `ReflectionFunction` and `ReflectionMethod`, through their shared parent `ReflectionFunctionAbstract`; - `ReflectionProperty` and `ReflectionClassConstant`; - `ReflectionParameter` for individual parameters; - `ReflectionConstant` for global constants, new in PHP 8.5. One detail surprises people: an attribute written on a **promoted constructor parameter** is replicated to both the parameter and the property it declares, so `#[Email] public string $address` in a constructor shows up on both `ReflectionParameter` and `ReflectionProperty`. A validator that scans properties and a container that scans parameters will each see it. ## Filtering by name and by type `getAttributes(Route::class)` returns only attributes whose resolved name is exactly `Route`. That is often too strict: a permission system may define `RequiresRole` and `RequiresScope`, both extending an abstract `Permission` base or implementing a `PermissionAttribute` interface. Passing the flag `ReflectionAttribute::IS_INSTANCEOF` — `getAttributes(PermissionAttribute::class, ReflectionAttribute::IS_INSTANCEOF)` — matches every attribute whose class is that type or a subtype. With that flag the filter class must exist (otherwise an `Error` "Class ... not found"), and any flag value other than `0` or `IS_INSTANCEOF` throws a `ValueError`. ## getArguments() versus newInstance() - **`getArguments()`** is cheap and never touches the attribute class, which suits tooling that only needs the literal values, or code that must cope with attribute classes that are not installed. - **`newInstance()`** gives a typed, validated object and runs whatever checks the constructor performs, such as rejecting a path without a leading `/`. Most framework code uses `newInstance()` so that bad metadata fails loudly. ## Common mistakes - Forgetting `#[Attribute]` on the class: `newInstance()` throws `Attempting to use non-attribute class "..." as attribute`. - Expecting `getAttributes()` to return `Route` objects directly. - Filtering by a base class without `IS_INSTANCEOF` and getting an empty array. - Putting service dependencies in the attribute constructor: PHP calls it with the literal arguments only, so there is nothing to inject.

  • Which strict_types setting applies when newInstance() calls a PHP attribute's constructor?
    The one in the file where the attribute is written. `#[MyAttr("42")]` with an `int` parameter is coerced to `42` in a weak-mode file but throws a `TypeError` if that file declares `strict_types=1`, whatever mode the file calling `newInstance()` uses.
  • When would you use ReflectionAttribute::getArguments() instead of newInstance()?
    When you need only the literal values and must not depend on the attribute class being loadable, for example a code generator or an analyser scanning third-party code. It skips autoloading, constructor checks and target validation, so the values are unvalidated.
  • What happens if you pass an interface name to getAttributes() without any flag?
    It matches only attributes whose resolved class name equals that interface name exactly, which is usually none. Add `ReflectionAttribute::IS_INSTANCEOF` to match attribute classes implementing it; with that flag PHP must be able to load the interface.

saying these in an interview costs you the question

  • getAttributes() returns instances of the attribute classes directly.
  • An attribute class must extend a special base class or implement an interface.
  • getAttributes(Base::class) automatically includes subclasses of Base.
  • The attribute constructor runs when the controller class is loaded.
  • Attribute constructors can receive services from the dependency container.
open as a page

In PHP, how does a tiny dependency-injection container use ReflectionClass and getParameters() to autowire a class's constructor dependencies?

level: middleimportance: must knowfreq 45%

basics

~20 s

For a requested class it calls getConstructor(), walks getParameters(), and for each parameter whose type is a ReflectionNamedType naming a class, resolves that class recursively; scalars need a default or explicit config. It then calls newInstanceArgs() with the resolved list.

open as a page

In PHP 8, how do native attributes written as #[...] differ from docblock annotations such as @Route inside a /** */ comment?

level: juniorimportance: should knowfreq 45%

basics

~20 s

Attributes are real PHP syntax: the engine parses them, resolves their names like class names and returns them through Reflection's getAttributes(). Docblock annotations are comment text a userland library must fetch with getDocComment() and parse itself.

open as a page

In PHP, how do you create an object when its class name is only known at run time, and when is ReflectionClass::newInstanceArgs() actually needed?

level: juniorimportance: should knowfreq 30%

basics

~20 s

Plain new already accepts a variable: new $class(...$args), with string keys passed as named arguments. ReflectionClass::newInstanceArgs() does the same; reflection is needed when you must inspect the class first, or build it without its constructor or lazily.

open as a page

In PHP 8.2 and later, what does the #[\SensitiveParameter] attribute redact, and what does it leave exposed?

level: middleimportance: should knowfreq 22%

basics

~20 s

It replaces the marked argument with a SensitiveParameterValue object in every backtrace PHP builds, so exception traces and debug_backtrace() no longer print it. The function body, var_dump(), logging and callees without the attribute still see the real value.

open as a page

In PHP, what do Attribute::TARGET_* and Attribute::IS_REPEATABLE control on a custom attribute, and when is a violation actually reported?

level: middleimportance: should knowfreq 28%

basics

~20 s

The TARGET_* flags restrict which declarations an attribute may sit on and IS_REPEATABLE allows it more than once per declaration. For userland attributes PHP enforces both only when ReflectionAttribute::newInstance() runs, by throwing an Error; built-in attributes fail at compile time.

open as a page

In PHP 8.1 and later, how do you read or write a private property through ReflectionProperty, and what does setAccessible() still do?

level: middleimportance: should knowfreq 35%

basics

~20 s

Since PHP 8.1, ReflectionProperty::getValue() and setValue() work on private and protected properties directly. setAccessible() does nothing, and PHP 8.5 deprecates calling it. Reflection can initialize an uninitialized readonly property but cannot change an initialized one.

open as a page

A small PHP framework maps #[Route] and #[RequiresPermission] attributes on controller methods to routes and access checks; how should it scan, validate and cache them safely?

level: seniorimportance: should knowfreq 30%

basics

~20 s

Scan each controller method's getAttributes() once at build time, instantiate every attribute so errors fail CI, deny routes that lack a permission attribute, and write the result to a generated PHP file so requests never repeat the reflection scan.

open as a page

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?

level: seniorimportance: should knowfreq 28%

basics

~20 s

Each 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.

open as a page

In PHP 8.4 and 8.5, what happens when code calls a function marked #[\Deprecated] or ignores the result of one marked #[\NoDiscard]?

level: middleimportance: nice to knowfreq 15%

basics

~20 s

Calling a #[\Deprecated] user function emits E_USER_DEPRECATED, including the optional since and message. Calling a #[\NoDiscard] function (PHP 8.5) as a bare statement emits a warning unless the result is used or cast with (void).

open as a page

In PHP 8.4 and later, how do lazy ghosts from newLazyGhost() differ from lazy proxies from newLazyProxy(), and when would a container choose each?

level: seniorimportance: nice to knowfreq 18%

basics

~20 s

A lazy ghost is initialized in place: its initializer fills the same object. A lazy proxy's factory returns a separate real instance the proxy forwards to, so identities differ; proxies suit objects someone else constructs.

open as a page