Attributes — Runtime Reflection and Framework Patterns

Go beyond attribute syntax into how attributes actually get used at runtime: constructing attribute classes with rich constructor arguments, reading them with ReflectionAttribute (getArguments() vs newInstance()), building a minimal router to prove that attributes are inert metadata until Reflection code reads them, using attributes for dependency injection and ORM mapping, and the newer built-in #[Override], #[Deprecated], and #[NoDiscard] attributes.

The earlier Attributes lesson covered the syntax: how to declare an attribute class, restrict its targets, make it repeatable, and read it back with getAttributes(). That's the grammar. This lesson is about the runtime story that syntax alone doesn't tell you -- what actually happens (and, just as importantly, what does NOT automatically happen) when a class or method is decorated with an attribute. A very common misconception among developers new to attributes is that writing #[Route('/users')] above a controller class somehow wires that route into the application by itself, the way a Python decorator can wrap and replace a function at definition time. It does not. An attribute is 100% inert data sitting next to a declaration -- nothing executes, nothing gets registered, nothing gets validated, until some other piece of code explicitly opens up Reflection, asks for that attribute, and acts on what it finds. This lesson builds that missing piece: real ReflectionAttribute usage, a working (if minimal) router built entirely from class-level attributes, dependency injection and ORM mapping patterns, and the newest built-in attributes the engine ships today.

Custom Attribute Classes with Real Constructor Arguments

In practice, attribute classes carry more than one trivial string. A routing attribute needs a path, an HTTP method list, maybe a name and middleware list; a validation attribute needs bounds, a message, maybe a group. Because an attribute class is just a class, its constructor can use everything the language already offers: typed properties promoted straight from the constructor, default values, enum-typed parameters (PHP 8.1+), union types, and named arguments for readability at the call site.

A class-level routing attribute is a good example of a richer shape: #[Attribute(Attribute::TARGET_CLASS)] final class Route { public function __construct(public string $prefix, public array $middleware = [], public ?string $name = null) {} }. Notice this one is restricted to TARGET_CLASS -- deliberately different from a method-level #[Route], because this lesson is going to attach routing information to the *controller class itself* and let individual public methods double as the action names, a pattern several real frameworks use for simple resource controllers.

Constructor arguments in an attribute are evaluated at compile time as constant expressions -- literals, other constant expressions, enum cases, and (as of PHP 8.1) new in initializers -- not arbitrary runtime code. You cannot write #[Route(getPathFromConfig())]; the argument has to be something PHP can represent as a fixed value baked into the compiled attribute data, which is exactly what lets getArguments() retrieve it later without evaluating anything.

• Attribute constructors can use promoted properties, defaults, enums, and named arguments like any other class. • Arguments must be compile-time constant expressions -- no calling arbitrary functions inside #[...]. • A class-level attribute (Attribute::TARGET_CLASS) and a method-level attribute of the same conceptual name (Route) can coexist as different classes with different targets. • Rich attributes (path + middleware + name) are the norm in real routing/ORM/validation libraries, not the single-string toy examples.

ReflectionAttribute in Depth: getArguments() vs newInstance()

A ReflectionAttribute gives you two fundamentally different ways to get at the data written inside #[...]. getArguments() returns the raw arguments exactly as an array -- positional arguments get integer keys, named arguments get string keys matching the parameter name -- without ever calling the attribute class's constructor. This is cheap and side-effect free, useful when you just need to inspect values (say, for a debugging tool or a static analyzer) without caring whether the attribute class's constructor does any validation.

newInstance() is the one that actually matters for application behavior: it invokes the attribute class's real constructor with those arguments, running any type checks, default value resolution, or constructor logic the class defines, and hands you back a genuine object of that class. This is the call almost every framework uses in production, because it wants a real Route or Column object with typed properties, not a loosely-typed array of raw values.

Because newInstance() genuinely invokes a constructor, it can throw -- a TypeError if the arguments don't satisfy the constructor's parameter types, for instance -- exactly like calling new Route(...) directly would. Code that scans many attributes across a whole codebase (a router boot step, say) should expect and handle that possibility rather than assuming every attribute it finds is well-formed.

• getArguments() -- raw array of the literal values written in #[...], no constructor call, cannot fail. • newInstance() -- actually constructs the attribute object; runs real constructor logic and type checks. • Named arguments in #[...] come back from getArguments() keyed by parameter name; positional ones by integer index. • newInstance() can throw (e.g. TypeError) exactly like a normal new SomeClass(...) call would.

Attributes Are Metadata, Not Behavior: Proving It with a Minimal Router

This is the single most important idea in this lesson, so it's worth stating plainly: #[Route('/users')] class UserController {} by itself does nothing whatsoever. There is no routing table, no HTTP dispatch, no magic. PHP parses the attribute, stores its class name and constructor arguments as static metadata attached to the UserController class definition, and moves on. If your application never runs any Reflection code that looks for #[Route] attributes, that route simply does not exist as far as incoming requests are concerned -- visiting /users would 404 exactly as if the attribute weren't there at all.

What makes routing 'work' is entirely separate, ordinary application code: a boot-time step that walks every controller class with ReflectionClass, calls getAttributes(Route::class) on each one, and -- only if it finds a Route attribute -- calls newInstance() on it and records the resulting path/class pair in an actual routing table (an array, or some in-memory structure the dispatcher consults per request). The examples below build exactly this, end to end, so the mechanism is visible rather than assumed.

This distinction matters practically: it means you can always answer 'what does this attribute actually do?' by finding the Reflection code that reads it. If no such code exists in the codebase, the attribute is dead weight -- present in the source, invisible to the running application. It also means attributes are safe by construction: attaching one to a class you don't control (say, in a shared library) can never trigger unexpected behavior in that library unless that library ships code that explicitly reads it.

• An attribute on its own has zero runtime effect -- it is parsed, stored, and otherwise ignored. • 'Wiring up' an attribute always requires separate, explicit Reflection code that reads it and acts on the result. • If you can't point to the getAttributes()/newInstance() call that reads an attribute, that attribute does nothing in the running app. • This makes attributes safe to attach speculatively -- they can't trigger surprise behavior without matching reader code.

Attributes for Dependency Injection

A dependency injection container needs to decide what to construct for each constructor parameter of a class it's asked to build. Attributes give it a declarative way to be told explicitly, rather than guessing purely from type hints. A parameter-level #[Inject] attribute (Attribute::TARGET_PARAMETER) can carry an explicit service name or configuration key: public function __construct(#[Inject('logger.file')] private LoggerInterface $logger) {}, letting the container resolve 'logger.file' from its bindings instead of just resolving by the LoggerInterface type hint (which might be ambiguous if several implementations are registered).

The resolving code walks a class's constructor with ReflectionMethod::getParameters(), and for each ReflectionParameter calls getAttributes(Inject::class) -- if present, it uses the attribute's binding name to look up a concrete service; if absent, it falls back to resolving purely by the parameter's declared type. This mix of 'attribute overrides, type hint is the default' is exactly how autowiring containers in modern frameworks combine convention (types) with explicit configuration (attributes) at each individual parameter.

• #[Inject('service.name')] on a constructor parameter -- read via ReflectionParameter::getAttributes(). • Lets a DI container resolve an explicit binding instead of guessing solely from the type hint. • Falls back to type-hint-based autowiring when no #[Inject] attribute is present on that parameter.

ORM Mapping and Serialization

ORM entity mapping is one of the most attribute-heavy patterns in modern PHP: #[Entity] #[Table('users')] class User { #[Id] #[Column(type: 'int')] private int $id; #[Column(name: 'email_address', type: 'string')] private string $email; }. None of this creates a database table or generates SQL by itself -- it's read once (often cached after the first read, since Reflection has real overhead) by the ORM's metadata layer, which builds an internal map of 'this property corresponds to this column, with this type' that its query builder and hydrator then consult on every subsequent operation.

Serialization libraries lean on the exact same mechanism for a different purpose: mapping a PHP property name to the key it should have in an external representation like JSON, independent of PHP's own naming rules. #[SerializedName('created_at')] private DateTimeImmutable $createdAt; lets a serializer walk an object's properties via Reflection, and for each one check for a #[SerializedName] attribute to decide the output key, falling back to the property's own name when no attribute is present.

Both patterns share the same three-step shape you've now seen repeated in routing, DI, and ORM mapping: reflect over the declaration, look for a specific attribute class, and -- only if found -- instantiate it and use its data to drive behavior that lives entirely in ordinary application/library code, never in the attribute itself.

• ORM: #[Entity], #[Table], #[Id], #[Column(name:, type:)] -- read once by the ORM's metadata layer, often cached. • Serialization: #[SerializedName('json_key')] maps a PHP property to an external field name. • Same three-step pattern every time: reflect -> find the specific attribute -> instantiate and use its data.

New Built-in Attributes: #[Override], #[Deprecated], #[NoDiscard]

Not every attribute is user-defined -- PHP itself now ships a handful of built-in attributes (in the root namespace, alongside #[Attribute] and #[SensitiveParameter]) that the engine understands natively, without any Reflection-based reader needed. #[\Override] (PHP 8.3+) goes on a method that's meant to override a parent class or interface method. If no matching method actually exists on a parent/interface -- because of a typo, or because the parent method was later renamed or removed -- PHP raises a compile-time error immediately, instead of silently letting you define an unrelated method that happens to share a name with nothing.

#[\Deprecated] (PHP 8.4+) formalizes what used to be an informal @deprecated docblock tag into something the engine actually enforces: #[\Deprecated(message: 'use generate() instead', since: '2.0')] on a method or function causes PHP to emit a real E_DEPRECATED notice every time that method or function is called, carrying your message and version -- visible in logs and error reporting the same way any other deprecation notice is, instead of relying on developers to notice a comment.

#[\NoDiscard] (PHP 8.5+) marks a function or method whose return value is a mistake to silently throw away -- think a validation function returning bool, or a 'Result'-style object meant to be checked. Calling a #[\NoDiscard] function purely for its side effects, without using the returned value in any way (not assigning it, not returning it, not passing it anywhere), triggers a warning, catching a real and common class of bug where a caller assumes a function performs an action when it actually just reports on one.

All three are still, structurally, attributes -- declarative metadata attached to a declaration -- but unlike #[Route] or #[Column], the reader isn't application code you write: it's the Zend Engine's compiler and runtime themselves, checking for these specific, reserved attribute names and enforcing behavior directly. They're a useful reminder that 'attributes need a reader' is a rule about attributes in general -- the reader just happens to be built into the language for these three.

• #[\Override] (8.3+) -- compile-time error if the method doesn't actually override anything in a parent/interface. • #[\Deprecated(message:, since:)] (8.4+) -- engine emits a real E_DEPRECATED notice on every call, no docblock parsing needed. • #[\NoDiscard] (8.5+) -- warns when a call's return value is completely ignored by the caller. • These are still attributes structurally -- the difference is the engine itself is the reader, not user-written Reflection code.

Quick Reference: Reading Attributes and the Built-in Engine Attributes

getAttributes(SomeAttribute::class) filters to just that attribute class among possibly many attached. getArguments() never runs constructor code; newInstance() always does, and can throw. #[\Override] and #[\Deprecated] are read and enforced by the engine itself, not by application Reflection code.

// Reading: raw arguments vs a real instantiated object
$attrs = (new ReflectionClass(UserController::class))->getAttributes(Route::class);
foreach ($attrs as $attr) {
    $raw = $attr->getArguments();     // e.g. ['prefix' => '/users']  -- no constructor called
    $route = $attr->newInstance();    // Route object -- constructor actually ran
}

// Built-in engine-enforced attributes
class Base
{
    public function handle(): void {}

    #[\Deprecated(message: 'use handle() instead', since: '2.0')]
    public function run(): void { $this->handle(); }
}

class Derived extends Base
{
    #[\Override] // compile error here if handle() didn't exist on Base
    public function handle(): void {}
}

Code Examples

Nothing about routing exists until register() runs: it is the loop calling getAttributes(Route::class) and newInstance() that turns static #[Route] metadata into a real, queryable routing table. Before register() is called, UserController and ProductController are just ordinary classes that happen to carry unused metadata. Expected result: list of users list of products 404 Not Found If register() were never called (or a controller were left out of the array passed to it), its #[Route] attribute would still be sitting right there in the source, doing precisely nothing -- proof that the attribute itself never wires anything up on its own.

$route = $attributes[0]->newInstance();

getArguments() returns ['min' => 0, 'max' => 100] purely as data -- the Range constructor's own validation (min > max) never executes. newInstance() is the call that actually runs that constructor, which is why it's the one that could throw if the attribute's written arguments were invalid (e.g. #[Range(min: 100, max: 0)] would throw InvalidArgumentException the moment newInstance() is called). Expected result: array(2) { ["min"]=> int(0) ["max"]=> int(100) } min=0 max=100 $range = $attribute->newInstance();

Common mistake: Assuming getAttributes() or getArguments() validates an attribute's data -- validation only happens if and when newInstance() actually runs the constructor.

The container inspects each constructor parameter with ReflectionParameter::getAttributes(Inject::class). When it finds one, it reads the explicit service name from the attribute and resolves that exact binding, instead of guessing purely from the LoggerInterface type hint -- which matters once more than one LoggerInterface implementation is registered in the container. Expected result: [file] report generated

$service = $injectAttrs[0]->newInstance()->service;

<?php
declare(strict_types=1);

#[Attribute(Attribute::TARGET_CLASS)]
final class Route
{
    public function __construct(
        public string $prefix,
        public array $middleware = [],
    ) {}
}

// The attribute alone does NOTHING -- it is just metadata until the Router below reads it.
#[Route('/users', middleware: ['auth'])]
final class UserController
{
    public function index(): string { return 'list of users'; }
}

#[Route('/products')]
final class ProductController
{
    public function index(): string { return 'list of products'; }
}

final class Router
{
    /** @var array<string, string> prefix => controller class name */
    private array $routes = [];

    /** @param class-string[] $controllers */
    public function register(array $controllers): void
    {
        foreach ($controllers as $controllerClass) {
            $reflection = new ReflectionClass($controllerClass);
            $attributes = $reflection->getAttributes(Route::class);

            if ($attributes === []) {
                continue; // no #[Route] attribute -- this class is simply not routed
            }

            $route = $attributes[0]->newInstance(); // actually constructs a Route ob
…
<?php
declare(strict_types=1);

#[Attribute(Attribute::TARGET_METHOD)]
final class Range
{
    public function __construct(public int $min, public int $max)
    {
        if ($min > $max) {
            throw new InvalidArgumentException('min cannot exceed max');
        }
    }
}

final class Product
{
    #[Range(min: 0, max: 100)]
    public function setDiscountPercent(int $value): void {}
}

$method = new ReflectionMethod(Product::class, 'setDiscountPercent');
$attribute = $method->getAttributes(Range::class)[0];

// getArguments(): raw data, keyed by name, constructor never runs.
var_dump($attribute->getArguments());

// newInstance(): actually builds a Range object -- constructor logic runs for real.
$range = $attribute->newInstance();
echo "min={$range->min} max={$range->max}\n";
<?php
declare(strict_types=1);

#[Attribute(Attribute::TARGET_PARAMETER)]
final class Inject
{
    public function __construct(public string $service) {}
}

interface LoggerInterface { public function log(string $message): void; }

final class FileLogger implements LoggerInterface
{
    public function log(string $message): void { echo "[file] $message\n"; }
}

final class ReportGenerator
{
    public function __construct(
        #[Inject('logger.file')] private LoggerInterface $logger,
    ) {}

    public function run(): void { $this->logger->log('report generated'); }
}

final class Container
{
    /** @var array<string, object> */
    private array $bindings = [];

    public function bind(string $name, object $instance): void
    {
        $this->bindings[$name] = $instance;
    }

    public function make(string $class): object
    {
        $constructor = new ReflectionMethod($class, '__construct');
        $args = [];

        foreach ($constructor->getParameters() as $param) {
            $injectAttrs = $param->getAttributes(Inject::class);

            if ($injectAttrs !== []) {
                $service = $injectAttrs[0]->newInstance()->service;
                $args[] =
…
<?php
declare(strict_types=1);

class LegacyReport
{
    public function build(): string { return 'built via build()'; }

    #[\Deprecated(message: 'use build() instead', since: '2.0')]
    public function generate(): string { return $this->build(); }
}

class QuarterlyReport extends LegacyReport
{
    #[\Override] // fine -- build() genuinely exists on LegacyReport
    public function build(): string { return 'quarterly: ' . parent::build(); }
}

$report = new QuarterlyReport();
echo $report->build() . "\n";

// This call triggers a real E_DEPRECATED notice generated by the engine,
// because generate() carries #[\Deprecated] -- no manual docblock parsing needed.
echo $report->generate() . "\n";

Attribute Metadata vs Attribute Behavior

Every arrow after the attribute declaration is code you (or a library) wrote and chose to run -- the attribute itself never crosses that first arrow on its own.

#[Route('/users')]
class UserController {}          <-- pure metadata, parsed and stored, nothing runs
        |
        |  (nothing happens here automatically)
        v
ReflectionClass::getAttributes()  <-- explicit application/library code, written by you
        |
        v
  ->newInstance()                 <-- constructor actually runs, real Route object exists
        |
        v
  Router records '/users' => UserController   <-- ONLY NOW does the route exist

Important Rules

An attribute has zero runtime effect until explicit Reflection code calls getAttributes() (and usually newInstance()) on it.

Use getArguments() for cheap, side-effect-free inspection; use newInstance() when you need a real, validated object.

newInstance() runs real constructor code and can throw -- code scanning many attributes should handle that possibility.

Attribute constructor arguments must be compile-time constant expressions, not arbitrary function calls.

Why This Matters in Production

Almost every 'why isn't my route/validation rule/DI binding working?' bug in an attribute-heavy framework traces back to the same root cause: the Reflection scanning step never ran, ran on the wrong list of classes, or was silently swallowing an exception from newInstance(). Understanding that attributes are inert until read turns a confusing 'the framework is broken' debugging session into a much simpler question: where is the getAttributes() call for this attribute, and did it actually run for this class? #[\Override] and #[\Deprecated], meanwhile, catch two of the most common refactor-time and API-evolution mistakes -- a broken override and an unnoticed deprecated call -- at compile time or with a visible runtime notice, instead of as a silent behavior change discovered weeks later.

An attribute with no reader code is dead metadata -- visiting the intended URL will 404 exactly as if the attribute were never written, because nothing ever turned it into an actual registered route.

$path = $attribute->getArguments()['prefix']; // used directly, skipping the attribute class's own constructor logic/validation

$route = $attribute->newInstance(); $path = $route->prefix; // goes through the real constructor, including any validation it performs getArguments() hands back raw, unvalidated values exactly as written in the source. If the attribute class's constructor performs any checks or normalization, skipping newInstance() means you bypass all of that silently.

[multiple-choice] What happens when you attach #[Route('/users')] to a class but never write any code that calls getAttributes() on it? A. PHP automatically registers the route at startup B. Nothing -- the attribute is stored as inert metadata and has zero effect on the running application C. PHP throws a compile-time error demanding a reader D. The route works, but only for GET requests

B. Nothing -- the attribute is stored as inert metadata and has zero effect on the running application — Attributes never execute or register themselves. Without explicit Reflection code reading them (getAttributes(), typically followed by newInstance()), an attribute is just parsed metadata sitting next to a declaration, with no effect on program behavior.

[multiple-choice] What is the key difference between ReflectionAttribute::getArguments() and ->newInstance()? A. They return identical data; newInstance() is just a slower alias B. getArguments() returns raw values without calling the attribute's constructor; newInstance() actually invokes the constructor and can throw C. getArguments() only works on repeatable attributes D. newInstance() never actually constructs an object, it just validates arguments

B. getArguments() returns raw values without calling the attribute's constructor; newInstance() actually invokes the constructor and can throw — getArguments() is a cheap, side-effect-free read of the literal values written in #[...]. newInstance() genuinely constructs the attribute object by calling its real constructor, which means it runs any validation/logic the constructor has and can throw.

[multiple-choice] What does the built-in #[\Override] attribute do? A. It automatically calls the parent class's version of the method before running the override B. It causes a compile-time error if the method doesn't actually override a method that exists on a parent class or interface C. It marks a method as safe to remove in a future version D. It prevents subclasses from overriding the method further

B. It causes a compile-time error if the method doesn't actually override a method that exists on a parent class or interface — #[\Override] (PHP 8.3+) is checked by the compiler: if no matching parent/interface method exists (due to a typo, rename, or removal), PHP raises a clear compile-time error instead of silently defining an unrelated method.

Extend the Router example so that ProductController's index() method also carries a method-level #[Middleware('cache')] attribute (Attribute::TARGET_METHOD), and modify Router::dispatch() to read and print any middleware names attached to the matched controller's index() method before calling it.

Declare #[Attribute(Attribute::TARGET_METHOD)] class Middleware { public function __construct(public string $name) {} }, attach #[Middleware('cache')] above ProductController::index(), then inside dispatch(): $method = new ReflectionMethod($controller, 'index'); foreach ($method->getAttributes(Middleware::class) as $attr) { echo 'middleware: ' . $attr->newInstance()->name . PHP_EOL; } before calling $controller->index().

You'll need a second attribute class targeting TARGET_METHOD, and inside dispatch() a ReflectionMethod on the resolved controller's index() method to call getAttributes(Middleware::class) on.