Evaluating PHP Attributes at Runtime: Using ReflectionAttribute Correctly
AI generated
<?php
8.4
PHP · Attributes · Reflection
Evaluating PHP Attributes at Runtime
Using ReflectionAttribute correctly

A PHP attribute is just metadata until someone reads it. Only ReflectionAttribute makes attributes usable at runtime, by extracting arguments, creating real instances and filtering by type. Understanding how ReflectionAttribute handles getArguments(), newInstance() and repeatable attributes lets you build validation, routing and serialization systems that need no external configuration files at all.

17 min read ReflectionAttribute · getArguments() · newInstance() PHP 8.0+ · 8.4

1. Why attributes only become useful at runtime

A PHP attribute is initially nothing more than declarative metadata placed next to a class, method, property or parameter in the source code. As long as nobody reads this metadata, an attribute has no effect on the program at all, the PHP parser simply recognizes it and stores it in the class's compiled metadata. The actual value only emerges once code deliberately searches for these attributes at runtime, reads them, and makes decisions based on the information found.

This article assumes that custom attributes are already defined, for instance with #[Attribute] on a class, and focuses entirely on the second half of the topic: how do you reliably and efficiently read attributes at runtime? The central class for this is ReflectionAttribute, an often underestimated link between the pure declaration of an attribute in code and a concrete, usable PHP instance at runtime.

In practice, evaluating attributes comes up wherever frameworks want to avoid configuration files: routing definitions directly on controller methods, validation rules directly on class properties, serialization hints directly on data fields. All of these patterns only work because, at runtime, the declared attribute reliably turns into a real object instance populated with arguments.

2. ReflectionAttribute: the bridge to an attribute instance

ReflectionAttribute is not instantiated directly, it is returned by getAttributes() from any reflection object that can carry attributes: ReflectionClass, ReflectionMethod, ReflectionProperty, ReflectionParameter and more. Every call to getAttributes() returns an array of ReflectionAttribute objects, one per attribute declared at that location, regardless of whether the attribute is ever actually instantiated.

This is an important distinction from a naive approach: you initially get only metadata about the attribute, not automatically an instance of the underlying class. The reason is performance: not every caller actually needs an instance, sometimes the plain name of the attribute is enough. ReflectionAttribute::getName() returns exactly that name without PHP having to run the attribute class's constructor.


<?php

declare(strict_types=1);

#[Attribute(Attribute::TARGET_PROPERTY)]
final class Validate
{
    public function __construct(
        public readonly string $rule,
        public readonly ?string $message = null,
    ) {
    }
}

final class RegisterRequest
{
    #[Validate('email', message: 'Invalid email address')]
    public string $email = '';

    #[Validate('min_length:8')]
    public string $password = '';
}

$reflection = new ReflectionClass(RegisterRequest::class);

foreach ($reflection->getProperties() as $property) {
    // getAttributes() returns metadata objects, not instances yet
    foreach ($property->getAttributes() as $attribute) {
        echo $property->getName() . ' -> ' . $attribute->getName() . PHP_EOL;
    }
}

3. Filtering attributes by type with IS_INSTANCEOF

In real applications, classes and properties often carry several different attributes at once, one for validation and one for serialization, for example. If you are only interested in a specific attribute type, you should not fetch every attribute and filter manually, but instead call getAttributes() directly with a class name and the flag ReflectionAttribute::IS_INSTANCEOF. That returns only attributes that are exactly this type or a subclass of it, and is considerably more readable than filtering afterward with array_filter.

The IS_INSTANCEOF flag is especially valuable when a system supports several related attribute types, for example an abstract Rule base class with concrete subclasses like EmailRule and LengthRule. Without this flag, the calling code would need to know every concrete attribute name individually; with the flag, knowing the shared base class or interface is enough.


<?php

declare(strict_types=1);

function findValidationAttributes(ReflectionProperty $property): array
{
    // Only fetch attributes matching Validate or a subclass of it
    return $property->getAttributes(Validate::class, ReflectionAttribute::IS_INSTANCEOF);
}

$reflection = new ReflectionClass(RegisterRequest::class);

foreach ($reflection->getProperties() as $property) {
    $validationAttributes = findValidationAttributes($property);

    if ($validationAttributes === []) {
        continue;
    }

    echo $property->getName() . ' has ' . count($validationAttributes) . ' validation rule(s)' . PHP_EOL;
}

4. Reading arguments: getArguments() versus newInstance()

Once you have a ReflectionAttribute object, there are two fundamentally different ways to get at the actual values. getArguments() returns the raw arguments passed in the code as an associative or mixed array, without running the attribute class's constructor. newInstance(), on the other hand, actually creates an object of the attribute class, invoking its constructor, and returns a fully initialized instance.

The choice between the two has noticeable consequences. getArguments() is faster because no constructor runs, but you lose any type checking and validation logic that might live in the attribute class's constructor. newInstance() is slower but guarantees that the result is a valid object of the attribute class, including all constructor invariants. For validation and routing systems, where correctness matters more than the last bit of performance, newInstance() is almost always the right choice.


<?php

declare(strict_types=1);

$reflection = new ReflectionClass(RegisterRequest::class);
$property = $reflection->getProperty('email');

foreach ($property->getAttributes(Validate::class) as $attribute) {
    // Raw arguments, no constructor call, no type checking
    $raw = $attribute->getArguments();
    var_dump($raw); // ['email', 'message' => 'Invalid email address']

    // Full instance, constructor runs, readonly properties are set
    $instance = $attribute->newInstance();
    echo $instance->rule . PHP_EOL;    // email
    echo $instance->message . PHP_EOL; // Invalid email address
}

5. Repeatable attributes: handling multiple instances

By default, an attribute may only be declared once at a given location in the code. For cases like several routes on the same method, or several validation rules on the same field, that is too restrictive. PHP solves this with the flag Attribute::IS_REPEATABLE, set when the attribute is defined. If this flag is set, the parser accepts multiple declarations of the same attribute at the same location, and getAttributes() returns multiple ReflectionAttribute objects accordingly.

A typical example is a Route attribute that lets a controller method be registered for several HTTP methods or several URL paths at once. Without IS_REPEATABLE, you would either have to pass an array as a constructor argument, which hurts readability compared to several standalone attribute declarations, or fall back to several differently named attributes, which is equally inelegant.


<?php

declare(strict_types=1);

#[Attribute(Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE)]
final class Route
{
    public function __construct(
        public readonly string $method,
        public readonly string $path,
    ) {
    }
}

final class OrderController
{
    #[Route('GET', '/orders')]
    #[Route('GET', '/orders/list')]
    public function index(): array
    {
        return [];
    }
}

$method = new ReflectionMethod(OrderController::class, 'index');

foreach ($method->getAttributes(Route::class) as $attribute) {
    $route = $attribute->newInstance();
    echo $route->method . ' ' . $route->path . PHP_EOL;
}

6. Practical example: an attribute-driven validator

A realistic use case for evaluated attributes is a simple validator that reads rules directly from the properties of a data object, instead of maintaining a separate validation configuration. The validator iterates over all properties of a class, reads the Validate attributes with newInstance(), and applies each rule to the property's current value. If a value is missing or violates the rule, the error message stored in the attribute is collected.

The decisive advantage over an external configuration file is the proximity of rule and field: when the data model changes, the validation rule changes right next to it in the same source code, without a second file to keep in sync. Modern validation libraries in Symfony and Laravel use exactly this pattern, internally relying on the very same combination of getAttributes() and newInstance() shown here.

7. Practical example: attribute-driven routing

Attribute-driven routing is another common pattern, where instead of a central routing file, each controller method carries its own Route declaration directly in the code. When the application starts, a router scans all controller classes, reads their methods via ReflectionClass::getMethods(), and collects the Route attributes for each method into a central routing table. This build step ideally runs only once, not on every single request.

The router itself then uses the collected route instances purely as a plain data structure, with no further reflection calls in the hot path. This separation, reflection only during setup, direct array access on every request, is critical for the performance of an attribute-driven router and is explored further in the next section.

8. Performance: caching attribute lookups

Evaluating attributes via ReflectionAttribute is not free: getAttributes() searches the class's compiled metadata, and newInstance() additionally invokes the attribute class's constructor. In a routing system with hundreds of controller methods that rebuilds routes from attributes on every request, this cost adds up noticeably. The standard solution is to compute the result of attribute evaluation once and then store it in a cache, for example as a serialized array in a file or in an object cache like APCu.

It is important to distinguish between development and production environments here: during development, a change to an attribute should take effect immediately, while in production the cache is exactly the reason the system stays performant. Frameworks usually solve this with a warmup command that builds the attribute cache once during deployment, combined with a check on file modification timestamps during development.

9. Attributes compared to docblock annotations

Before native PHP attributes were introduced in PHP 8.0, comparable metadata was often expressed through docblock comments in free text form, such as @Route("/orders"), and evaluated at runtime by libraries like doctrine/annotations using regex or a docblock parser. The following table contrasts both approaches.

Criterion Docblock annotation PHP attribute with ReflectionAttribute Benefit
Syntax checking Only at runtime via regex/parser Checked by the PHP parser itself Errors visible already at opcode compile time
Typed arguments Free text only, parsed manually Constructor with real types PHPStan and IDEs understand the structure
Reading at runtime Docblock parser library required ReflectionAttribute::newInstance() Native language support, no extra dependency
Multiple values at one location Several @lines in the same comment Attribute::IS_REPEATABLE Clearly structured, individually typed
Performance Regex parsing on every access Compiled metadata, cacheable No text parsing at runtime

The table makes clear why native PHP attributes have largely replaced docblock annotations in modern codebases: they are validated and typed by the language parser and can be read via ReflectionAttribute without an extra library, while docblock annotations always required a separate parsing layer.

Mironsoft

PHP architecture, attribute-driven systems and framework development

Building attribute-driven validation or routing cleanly?

We build attribute-driven PHP systems, from the attribute definition through ReflectionAttribute-based evaluation to a caching strategy for production load.

Validation systems

Attribute-driven validators for forms and APIs

Routing architecture

Attribute-driven routing with performant caching

Performance audit

Checking reflection and attribute lookups under production load

10. Summary

A PHP attribute only unfolds its value once code reads it at runtime via ReflectionAttribute. getAttributes() returns metadata objects, getArguments() the raw constructor arguments without type checking, and newInstance() a fully validated instance of the attribute class. The IS_INSTANCEOF flag filters by type including inheritance, and the IS_REPEATABLE flag allows multiple declarations of the same attribute at the same location.

In practice, this combination produces attribute-driven validators and routers that keep configuration right next to the code instead of maintaining it in separate files. Because evaluating attributes via the Reflection API causes measurable overhead, caching evaluation results belongs in every production system that uses attributes at scale.

Evaluating PHP Attributes at Runtime — Key Takeaways

ReflectionAttribute

Bridge between a declared attribute and a real instance, returned via getAttributes().

getArguments() vs. newInstance()

Raw arguments without a constructor versus a full, validated instance with constructor.

IS_INSTANCEOF & IS_REPEATABLE

Filter by type including inheritance, allow multiple declarations at one location.

Performance

Compute attribute evaluation once and cache it, do not repeat it on every request.

11. FAQ: Evaluating PHP Attributes at Runtime

1What does getAttributes() return?
An array of ReflectionAttribute objects, metadata objects, not instances of the attribute class itself.
2getArguments() vs. newInstance()?
Raw arguments without a constructor versus a full, validated instance with constructor call.
3What is IS_INSTANCEOF for?
Filters getAttributes() to a type including subclasses, instead of manual filtering.
4What does IS_REPEATABLE mean?
Allows the same attribute to be declared multiple times at one location, such as multiple routes per method.
5Is attribute evaluation slow?
Measurable overhead, especially with newInstance(). Cache results instead of recomputing per request.
6Read attributes on parameters?
Yes, ReflectionParameter also has getAttributes() for individual method or function parameters.
7Must the class carry #[Attribute]?
Yes, without this marker PHP raises an error when it is used as an attribute.
8Invalid arguments in newInstance()?
The constructor throws a regular PHP exception, such as TypeError, handled like any other exception.
9Attributes vs. docblock annotations?
Attributes are typed and recognized by the parser, docblock annotations are plain text needing a separate parsing library.
10Where to place attribute evaluation?
Centrally in a bootstrap or warmup step with subsequent caching, not scattered across the code.