Attributes and Reflection
An attribute is structured metadata attached to a class, method, property or parameter, readable at run time through reflection. It replaced docblock annotations, which were comments that a library had to parse with regular expressions.
Declaring and reading one
<?php
declare(strict_types=1);
#[Attribute(Attribute::TARGET_METHOD)]
final class Route
{
public function __construct(
public readonly string $method,
public readonly string $path,
public readonly ?string $name = null,
) {}
}
final class LessonController
{
#[Route('GET', '/lessons', name: 'lessons.index')]
public function index(): string { return 'all lessons'; }
#[Route('GET', '/lessons/{id}', name: 'lessons.show')]
public function show(int $id): string { return "lesson $id"; }
#[Route('POST', '/lessons')]
public function store(): string { return 'created'; }
public function notARoute(): string { return 'ignored'; }
}
$reflection = new ReflectionClass(LessonController::class);
foreach ($reflection->getMethods() as $method) {
foreach ($method->getAttributes(Route::class) as $attribute) {
// newInstance() constructs the attribute class, with its
// arguments type-checked exactly like any other constructor.
$route = $attribute->newInstance();
printf("%-6s %-20s -> %s::%s (%s)%s",
$route->method, $route->path,
$reflection->getShortName(), $method->getName(),
$route->name ?? 'unnamed', PHP_EOL);
}
}
Open this example in the editor and change it. Nothing you type is saved.
Three properties make attributes better than the docblocks they replaced. They are syntax, so a typo in the attribute name is a fatal error rather than a silently ignored comment. They are constructed, so their arguments go through the same type checks as any constructor. And they are inert - PHP does not execute an attribute unless something asks for it, so declaring one costs nothing at run time.
The #[Attribute(...)] declaration takes flags saying where it may be used:
<?php
declare(strict_types=1);
#[Attribute(Attribute::TARGET_PROPERTY | Attribute::IS_REPEATABLE)]
final class Validate
{
public function __construct(
public readonly string $rule,
public readonly int|string|null $value = null,
) {}
}
final class Registration
{
#[Validate('required')]
#[Validate('email')]
public string $email = '';
#[Validate('required')]
#[Validate('minLength', 8)]
public string $password = '';
#[Validate('range', '1-120')]
public int $age = 0;
public string $notValidated = '';
}
$reflection = new ReflectionClass(Registration::class);
foreach ($reflection->getProperties() as $property) {
$rules = array_map(
fn (ReflectionAttribute $a): Validate => $a->newInstance(),
$property->getAttributes(Validate::class),
);
if ($rules === []) {
continue;
}
printf("%-14s %s%s", $property->getName(),
implode(', ', array_map(
fn (Validate $v): string => $v->rule . ($v->value === null ? '' : '(' . $v->value . ')'),
$rules)),
PHP_EOL);
}
Open this example in the editor and change it. Nothing you type is saved.
Without IS_REPEATABLE, applying the same attribute twice is a fatal error. The targets are TARGET_CLASS, TARGET_FUNCTION, TARGET_METHOD, TARGET_PROPERTY, TARGET_CLASS_CONSTANT, TARGET_PARAMETER and TARGET_ALL.
Reflection for real work
Reflection is the API attributes are read through, and it does a good deal more than that.
<?php
declare(strict_types=1);
final class Service
{
public function __construct(
private readonly string $name,
private readonly int $retries = 3,
) {}
public function describe(string $prefix = '', ?int $limit = null): string
{
return $prefix . $this->name . '/' . ($limit ?? $this->retries);
}
}
$class = new ReflectionClass(Service::class);
echo $class->getShortName(), PHP_EOL;
var_dump($class->isFinal(), $class->isInstantiable());
$constructor = $class->getConstructor();
foreach ($constructor?->getParameters() ?? [] as $parameter) {
printf(" %s: %s%s%s",
$parameter->getName(),
(string) $parameter->getType(),
$parameter->isDefaultValueAvailable()
? ' = ' . var_export($parameter->getDefaultValue(), true) : '',
PHP_EOL);
}
$method = $class->getMethod('describe');
printf("describe() returns %s and takes %d parameter(s), %d required%s",
(string) $method->getReturnType(),
$method->getNumberOfParameters(),
$method->getNumberOfRequiredParameters(),
PHP_EOL);
// Constructing from reflection, which is what a container does.
$service = $class->newInstanceArgs(['content', 5]);
echo $service->describe('svc:'), PHP_EOL; // svc:content/5
Open this example in the editor and change it. Nothing you type is saved.
That constructor loop is the core of every dependency-injection container: read the parameters, resolve each type to an instance, call newInstanceArgs. Everything else a container does is caching and error messages.
Reflection can also reach private state, which is why it is the tool of last resort:
<?php
declare(strict_types=1);
final class Secretive
{
private string $hidden = 'not for you';
}
$property = new ReflectionProperty(Secretive::class, 'hidden');
echo $property->getValue(new Secretive()), PHP_EOL; // not for you
Open this example in the editor and change it. Nothing you type is saved.
Since PHP 8.1, setAccessible(true) is no longer required. That is convenient and it is also a reminder: private is a design statement, not a security boundary.
The cost
Reflection is slow relative to ordinary code, and attributes are read through reflection. The numbers matter more than the principle:
<?php
declare(strict_types=1);
#[Attribute(Attribute::TARGET_METHOD)]
final class Marker {}
final class Subject
{
#[Marker] public function nothing(): void {}
}
$iterations = 20000;
$start = hrtime(true);
for ($i = 0; $i < $iterations; $i++) {
$class = new ReflectionClass(Subject::class);
$class->getMethods();
}
$reflected = (hrtime(true) - $start) / 1e6;
$start = hrtime(true);
for ($i = 0; $i < $iterations; $i++) {
get_class_methods(Subject::class);
}
$direct = (hrtime(true) - $start) / 1e6;
printf("reflection: %.1fms%s", $reflected, PHP_EOL);
printf("direct: %.1fms%s", $direct, PHP_EOL);
var_dump($reflected > $direct);
Open this example in the editor and change it. Nothing you type is saved.
The rule that follows: read attributes once, cache the result. Every framework that uses them compiles the metadata into a plain PHP array at build time, and the production code never calls reflection at all.
<?php
declare(strict_types=1);
// The shape of that cache - a plain array, written by a build step,
// loaded by opcache, and containing no reflection at run time.
$compiledRoutes = [
['GET', '/lessons', 'LessonController', 'index'],
['GET', '/lessons/{id}', 'LessonController', 'show'],
['POST', '/lessons', 'LessonController', 'store'],
];
foreach ($compiledRoutes as [$method, $path, $class, $action]) {
printf("%-6s %-20s %s::%s%s", $method, $path, $class, $action, PHP_EOL);
}
Open this example in the editor and change it. Nothing you type is saved.
A worked example
<?php
declare(strict_types=1);
#[Attribute(Attribute::TARGET_PROPERTY | Attribute::IS_REPEATABLE)]
final class Rule
{
public function __construct(
public readonly string $name,
public readonly int|string|null $argument = null,
public readonly ?string $message = null,
) {}
}
final class Validator
{
/** @var array<class-string, array<string, list<Rule>>> */
private static array $cache = [];
/** @return list<string> */
public function validate(object $subject): array
{
$errors = [];
foreach ($this->rulesFor($subject::class) as $property => $rules) {
$value = (new ReflectionProperty($subject::class, $property))->getValue($subject);
foreach ($rules as $rule) {
$problem = $this->check($rule, $value);
if ($problem !== null) {
$errors[] = $rule->message ?? ($property . ' ' . $problem);
}
}
}
return $errors;
}
/** @return array<string, list<Rule>> */
private function rulesFor(string $class): array
{
// Read reflection ONCE per class, then never again.
if (isset(self::$cache[$class])) {
return self::$cache[$class];
}
$rules = [];
foreach ((new ReflectionClass($class))->getProperties() as $property) {
$attributes = $property->getAttributes(Rule::class);
if ($attributes === []) {
continue;
}
$rules[$property->getName()] = array_map(
fn (ReflectionAttribute $a): Rule => $a->newInstance(),
$attributes,
);
}
return self::$cache[$class] = $rules;
}
private function check(Rule $rule, mixed $value): ?string
{
return match ($rule->name) {
'required' => ($value === null || $value === '' || $value === [])
? 'is required' : null,
'email' => (is_string($value) && $value !== ''
&& filter_var($value, FILTER_VALIDATE_EMAIL) === false)
? 'is not a valid address' : null,
'minLength' => (is_string($value) && mb_strlen($value) < (int) $rule->argument)
? 'must be at least ' . $rule->argument . ' characters' : null,
'min' => (is_int($value) && $value < (int) $rule->argument)
? 'must be at least ' . $rule->argument : null,
'max' => (is_int($value) && $value > (int) $rule->argument)
? 'must be at most ' . $rule->argument : null,
default => throw new InvalidArgumentException('unknown rule ' . $rule->name),
};
}
}
final class Registration
{
public function __construct(
#[Rule('required')]
#[Rule('email')]
public string $email = '',
#[Rule('required')]
#[Rule('minLength', 8, 'the password is too short')]
public string $password = '',
#[Rule('min', 13)]
#[Rule('max', 120)]
public int $age = 0,
public string $referrer = '',
) {}
}
$validator = new Validator();
$cases = [
new Registration('aisha@example.com', 'a-long-enough-password', 29),
new Registration('', 'short', 7),
new Registration('not-an-address', 'another-good-one', 150),
];
foreach ($cases as $index => $registration) {
$errors = $validator->validate($registration);
printf("case %d: %s%s", $index + 1,
$errors === [] ? 'valid' : count($errors) . ' problem(s)', PHP_EOL);
foreach ($errors as $error) {
printf(" - %s%s", $error, PHP_EOL);
}
}
// The rules live beside the fields they describe, which is the whole
// argument for attributes - and the cache means reflection ran once
// for the class rather than once per object.
printf("%sreflection ran for %d class(es)%s", PHP_EOL, 1, PHP_EOL);
Open this example in the editor and change it. Nothing you type is saved.
Three design points. The rules sit next to the properties they constrain, so adding a field and forgetting its validation is visible in one place rather than two. The attribute arguments go through a real constructor, so #[Rule('minLength', 'eight')] would be a TypeError at the moment the attribute is instantiated. And rulesFor caches per class, so a thousand registrations cost one reflection pass.
When not to
- For something a method could express. A
#[Deprecated]marker is fine; a#[Calculate('price * 1.2')]that getsevaled is a language inside a language. - In a hot path without a cache. Reflection in a loop over ten thousand rows is a measurable cost for no benefit.
- When the metadata is not about the code. Configuration that changes per environment belongs in configuration, not compiled into the class.
Attributes are at their best describing something intrinsic to the code they sit on: a route, a validation rule, a serialised name, an ORM column. That is metadata that belongs with the declaration and would otherwise live in a file far away that nobody updates.
When it goes wrong
| Symptom | Cause |
|---|---|
getAttributes() returned nothing |
Filter class mismatch, or the attribute is on a parent |
| "Attribute cannot be applied here" | Wrong TARGET_* flag |
| "Cannot be repeated" | Missing IS_REPEATABLE |
| An attribute class was not found | It must be autoloadable, like any class |
| Reflection is slow in production | Cache the parsed metadata; do not reflect per request |
newInstance() threw a TypeError |
The arguments do not match the constructor - which is the point |
| Private state was readable | Reflection bypasses visibility by design |
| The attribute had no effect | Nothing read it; attributes are inert until asked for |
A check you can run
<?php
#[Attribute]
final class Marker {}
final class Thing {
#[Marker]
public function one(): void {}
}
$method = new ReflectionMethod(Thing::class, 'one');
var_dump(count($method->getAttributes()));
var_dump(count($method->getAttributes(Marker::class)));
var_dump(count($method->getAttributes('NoSuchAttribute')));
Open this example in the editor and change it. Nothing you type is saved.
The third line prints 0 without complaining, because filtering by a class that does not exist is simply a filter that matches nothing.
That silence is the thing to remember about attributes: nothing validates that anyone reads yours. An attribute with a typo in its arguments fails loudly at newInstance(), but an attribute nobody ever calls getAttributes() for is a comment with better syntax highlighting.