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.
Table of Contents
- 1. Why attributes only become useful at runtime
- 2. ReflectionAttribute: the bridge to an attribute instance
- 3. Filtering attributes by type with IS_INSTANCEOF
- 4. Reading arguments: getArguments() versus newInstance()
- 5. Repeatable attributes: handling multiple instances
- 6. Practical example: an attribute-driven validator
- 7. Practical example: attribute-driven routing
- 8. Performance: caching attribute lookups
- 9. Attributes compared to docblock annotations
- 10. Summary
- 11. FAQ
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.