Attributes: Declarative Metadata, Targets, and Reflection
Learn PHP 8.0's native attribute syntax (#[...]) in depth — how to declare attributes, which language constructs they can target, how to make an attribute repeatable, how to read attributes at runtime with the Reflection API, and how this underpins modern framework, ORM, and validation patterns that used to rely on fragile docblock parsing.
Before PHP 8.0, if a framework wanted to attach structured metadata to a class, method, or property — 'this method is a route handler for GET /users', 'this property maps to the users.email database column', 'this field must be a valid email' — the only option was to encode that metadata as a specially-formatted comment (a docblock annotation like /** @Route("/users", methods={"GET"}) */) and parse it with a separate annotation-reading library at runtime. This worked, but comments are not real code: there's no syntax checking, no IDE understanding, and parsing them is slow and fragile. PHP 8.0 introduced attributes — #[...] syntax — as genuine, parsed, typed language constructs that attach structured metadata to declarations and can be read back reliably via the Reflection API. This is the same idea as Java annotations, C# attributes, or Python decorators used declaratively, and it now underlies routing, ORM mapping, dependency injection, and validation across essentially every modern PHP framework.
Declaring an Attribute Class
An attribute is just a regular PHP class, marked as usable-as-an-attribute by adding the built-in #[Attribute] attribute to its own declaration: #[Attribute] class Route { public function __construct(public string $path, public array $methods = ['GET']) {} }. Once declared this way, Route can be attached to other code using #[Route('/users', methods: ['GET', 'POST'])] syntax — this is just a constructor call written declaratively above a class, method, property, or parameter.
Because an attribute is a plain class, its constructor arguments follow all the normal rules — positional, named, defaults, variadics — and this pairs perfectly with named arguments (also PHP 8.0+) to make attribute usage highly readable: #[Route(path: '/users/{id}', methods: ['GET'])]. The attribute class's constructor parameters ARE the attribute's 'schema' — there's no separate metadata-definition step.
By default, #[Attribute] allows the attribute to be used on any single declaration it's attached to but does NOT restrict which kind of declaration (class, method, property, etc.) it can be used on, and does not allow the same attribute to be repeated on the same target — both of those are controlled by flags passed to #[Attribute(...)] itself, covered next.
• #[Attribute] class Route { public function __construct(public string $path) {} } — marks Route as usable as an attribute. • #[Route('/users')] above a class/method/property — attaches an instance of Route with those constructor arguments. • An attribute's constructor parameters define what data it can carry — no separate schema definition needed. • Named arguments pair naturally with attributes for readability: #[Route(path: '/users', methods: ['GET'])].
Targets: Restricting Where an Attribute Can Be Used
#[Attribute] accepts an optional bitmask argument restricting which kinds of declarations it may be attached to, using the Attribute::TARGET_* constants: TARGET_CLASS, TARGET_FUNCTION, TARGET_METHOD, TARGET_PROPERTY, TARGET_CLASS_CONSTANT, TARGET_PARAMETER, and TARGET_ALL (the default, meaning no restriction). For example, #[Attribute(Attribute::TARGET_METHOD)] class Route {} means Route can only be placed above a method — attaching it to a property or class is a compile-time error, which catches misuse early instead of silently doing nothing.
Multiple targets can be combined with bitwise OR: #[Attribute(Attribute::TARGET_CLASS | Attribute::TARGET_METHOD)] allows the attribute on either classes or methods, but not properties or parameters. This is how a framework enforces, at the language level, that e.g. a #[Column] mapping attribute only ever appears on properties, or a #[Route] attribute only ever appears on methods or controller classes — self-documenting and self-enforcing.
Choosing correct targets is a real design decision for anyone writing a reusable attribute (e.g. for a package): overly permissive targets (leaving the default TARGET_ALL) let consumers misuse the attribute in places that make no logical sense for your framework, producing confusing runtime behavior instead of an immediate, clear compile error.
• Attribute::TARGET_CLASS, TARGET_FUNCTION, TARGET_METHOD, TARGET_PROPERTY, TARGET_CLASS_CONSTANT, TARGET_PARAMETER, TARGET_ALL. • Combine with | (bitwise OR): Attribute::TARGET_CLASS | Attribute::TARGET_METHOD. • Using an attribute on a disallowed target is a compile-time error, caught before the code ever runs. • Default (no argument to #[Attribute]) is TARGET_ALL — unrestricted; explicitly restrict targets for any attribute meant for reuse.
Repeatable Attributes
By default, attaching the same attribute class twice to one declaration is a compile-time error — #[Route('/a')] #[Route('/b')] function handler() {} fails unless Route opts into being repeatable. Passing the Attribute::IS_REPEATABLE flag (combined with any target flags via |) allows the same attribute class to appear multiple times on the same declaration: #[Attribute(Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE)] class Route {} then allows a single method to be routed at multiple paths, e.g. both /users and /people mapping to the same handler.
When reading a repeatable attribute back via Reflection, getAttributes() naturally returns one entry per occurrence — there's no special API needed on the reading side, only the declaring side needs the IS_REPEATABLE flag. This makes repeatable attributes a clean way to express one-to-many metadata (a method with several valid routes, a class implementing several event-listener bindings) without inventing an array-valued constructor parameter that would otherwise be needed to hold multiple values in a single attribute instance.
• Attribute::IS_REPEATABLE — allows the same attribute class to be attached more than once to one declaration. • Combine with target flags: Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE. • Without IS_REPEATABLE, a duplicate attribute on the same declaration is a compile-time error. • Reflection's getAttributes() returns one entry per repetition automatically — no special handling needed to read repeated attributes.
Reading Attributes at Runtime with Reflection
Attributes are inert at runtime by themselves — attaching #[Route('/users')] to a method does absolutely nothing on its own; it's just stored metadata until something explicitly reads it via the Reflection API. The core call is getAttributes() on the relevant Reflection object (ReflectionClass, ReflectionMethod, ReflectionProperty, ReflectionFunction, ReflectionParameter), which returns an array of ReflectionAttribute objects — one per attribute instance found.
A ReflectionAttribute gives you the attribute's class name via getName() and its raw constructor arguments via getArguments() (as an array, without instantiating anything), or, more commonly, the fully-instantiated attribute object via newInstance() — which actually calls the attribute class's constructor with the arguments that were written in the #[...] and gives you back a real object you can call methods on: $route = $attribute->newInstance(); echo $route->path;.
getAttributes() optionally takes a class name filter as its first argument, which is how frameworks efficiently look for one specific attribute type among potentially many attached to the same declaration: $reflection->getAttributes(Route::class) returns only Route attributes, ignoring any others present. This filtered lookup is the backbone of how a router scans every method in every controller class at boot time, looking specifically for #[Route] attributes and ignoring everything else attached to those methods.
• getAttributes() on ReflectionClass/Method/Property/Function/Parameter — returns ReflectionAttribute[] instances. • $attr->getName() — the attribute class's name as a string. • $attr->getArguments() — raw constructor arguments, without instantiating the attribute class. • $attr->newInstance() — actually constructs the attribute object, invoking its real constructor. • getAttributes(SomeAttribute::class) — filters to only attributes of that specific class.
Framework, ORM, and Validation Use Cases
Attributes replaced docblock annotations as the standard way frameworks implement declarative routing: #[Route('/users/{id}', methods: ['GET'])] public function show(int $id) {} lets a router scan all controller methods at startup, read each #[Route] via Reflection, and build its routing table — no separate routes file needed, and the routing metadata lives right next to the code it describes, which stays in sync automatically because it's checked by the parser.
ORMs use attributes for entity-to-database mapping: #[Column(name: 'email_address', type: 'string')] private string $email; or #[Entity] #[Table(name: 'users')] class User {}. The ORM's metadata layer reads these via Reflection once (often caching the result) to know how to build SQL and hydrate objects, replacing older approaches that needed separate XML/YAML mapping files or docblock parsing.
Validation libraries express per-property rules declaratively: #[Assert\NotBlank] #[Assert\Email] private string $email; #[Assert\Length(min: 8)] private string $password;. A validator walks a class's properties via Reflection, collects every Assert\* attribute on each one, instantiates each rule with newInstance(), and runs it against the property's current value — meaning validation rules live directly next to the data they validate, are type-checked at parse time (unlike a typo'd docblock tag, which silently does nothing), and show up correctly in IDE autocompletion.
Because attribute arguments are ordinary PHP expressions evaluated with real syntax checking (not text parsed out of a comment), IDEs can offer autocomplete and type checking for them, static analysis tools can catch mistakes before runtime, and 'go to definition' works on the attribute class itself — this tooling win, more than any single feature, is why the ecosystem moved decisively away from docblock annotations once PHP 8.0 shipped.
• Routing: #[Route('/path', methods: [...])] on controller methods, scanned via Reflection at app boot. • ORM mapping: #[Entity], #[Table], #[Column], #[Id] on classes/properties describing database structure. • Validation: #[Assert\NotBlank], #[Assert\Email], #[Assert\Length(min: 8)] stacked directly on properties. • Attributes are real, parsed syntax — unlike docblock annotations, they get IDE support, static analysis, and parse-time error checking.
Quick Reference: Declaring, Restricting, and Reading Attributes
#[Attribute(Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE)]
class Route
{
public function __construct(
public string $path,
public array $methods = ['GET'],
) {}
}
class UserController
{
#[Route('/users', methods: ['GET'])]
#[Route('/people', methods: ['GET'])] // repeatable — same method, two routes
public function index(): array { /* ... */ return []; }
}
// Reading attributes back via Reflection:
$method = new ReflectionMethod(UserController::class, 'index');
foreach ($method->getAttributes(Route::class) as $attr) {
$route = $attr->newInstance(); // actually constructs a Route object
echo "{$route->path} => " . implode(',', $route->methods) . "\n";
}
// Output:
// /users => GET
// /people => GETCode Examples
Description is restricted to TARGET_CLASS, so attaching it to a method or property would be a compile error. Reading it back is a three-step dance that's the same for every attribute: get the ReflectionClass, call getAttributes() filtered by the attribute's class name, then newInstance() the one you want. Expected result: Represents a registered user of the system.
$attributes = $reflection->getAttributes(Description::class);
Because Route was declared with IS_REPEATABLE, all three #[Route(...)] declarations on list() are legal, and getAttributes(Route::class) returns all three as separate ReflectionAttribute entries in declaration order — this is exactly the mechanism a real router uses to register one handler under several URL paths. Expected result: /products [GET] /items [GET] /catalog [GET, HEAD]
This is a simplified but real illustration of how validation libraries work: getProperties() walks every property on the object, and for each one, getAttributes(SomeRule::class) checks whether that specific rule attribute is present before applying its logic. Adding a new validation rule to a field is just adding another #[Attribute] line above the property — no separate rules file to keep in sync. Expected result: Array ( [0] => username must not be blank [1] => password must be at least 8 characters) foreach ($property->getAttributes(MinLength::class) as $attr) {... }
#[Attribute(Attribute::TARGET_CLASS)]
class Description
{
public function __construct(public string $text) {}
}
#[Description('Represents a registered user of the system.')]
class User {}
$reflection = new ReflectionClass(User::class);
$attributes = $reflection->getAttributes(Description::class);
if (count($attributes) > 0) {
$description = $attributes[0]->newInstance();
echo $description->text;
}#[Attribute(Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE)]
class Route
{
public function __construct(public string $path, public array $methods = ['GET']) {}
}
class ProductController
{
#[Route('/products')]
#[Route('/items')]
#[Route('/catalog', methods: ['GET', 'HEAD'])]
public function list(): array { return []; }
}
$method = new ReflectionMethod(ProductController::class, 'list');
foreach ($method->getAttributes(Route::class) as $attr) {
$route = $attr->newInstance();
printf("%s [%s]\n", $route->path, implode(', ', $route->methods));
}#[Attribute(Attribute::TARGET_PROPERTY)]
class NotBlank {}
#[Attribute(Attribute::TARGET_PROPERTY)]
class MinLength
{
public function __construct(public int $min) {}
}
class SignupForm
{
#[NotBlank]
public string $username = '';
#[NotBlank]
#[MinLength(8)]
public string $password = '';
}
function validate(object $obj): array
{
$errors = [];
$reflection = new ReflectionClass($obj);
foreach ($reflection->getProperties() as $property) {
$value = $property->getValue($obj);
foreach ($property->getAttributes(NotBlank::class) as $attr) {
if ($value === '') {
$errors[] = "{$property->getName()} must not be blank";
}
}
foreach ($property->getAttributes(MinLength::class) as $attr) {
$rule = $attr->newInstance();
if (strlen($value) < $rule->min) {
$errors[] = "{$property->getName()} must be at least {$rule->min} characters";
}
}
}
return $errors;
}
$form = new SignupForm();
$form->password = 'short';
print_r(validate($form));Declare -> Attach -> Reflect -> Instantiate
Attributes are inert metadata until something explicitly reads them through Reflection and instantiates them — attaching one does not run any code on its own.
#[Attribute(TARGET_METHOD)] declares Route as a usable attribute,
class Route { ... } restricted to methods only
|
v
#[Route('/users')] attaches an inert metadata record
public function index() {} to the index() method (does nothing by itself)
|
v
ReflectionMethod::getAttributes(Route::class)
| finds the recorded #[Route(...)] entries
v
ReflectionAttribute::newInstance()
| actually calls Route's constructor now
v
Route { path: '/users', methods: ['GET'] } <- real object, usable like any otherImportant Rules
Mark a class usable as an attribute with #[Attribute] on the class itself; its constructor parameters define the attribute's data shape.
Restrict targets explicitly (TARGET_CLASS, TARGET_METHOD, etc.) for any attribute meant to be reused by others — the default is unrestricted.
Add Attribute::IS_REPEATABLE only when the same declaration legitimately needs multiple instances of that attribute.
Attributes do nothing by themselves — always pair a defined attribute with the Reflection code that reads and acts on it.
Filter getAttributes(SomeAttribute::class) by class name rather than iterating all attributes and checking types manually.
Where this shows up in production PHP
Symfony and Laravel both moved their routing, dependency-injection service tagging, event-listener registration, and validation metadata onto attributes once PHP 8.0 became a viable minimum version, largely replacing the older docblock-annotation libraries (like doctrine/annotations) that used to parse @Route(...)-style comments with a custom mini-parser. Doctrine ORM's attribute mapping (#[ORM\Entity], #[ORM\Column]) is now the default over its older XML/YAML/docblock mapping drivers. PHPUnit uses attributes like #[Test] and #[DataProvider('...')] as the modern replacement for @test/@dataProvider docblock tags. In every one of these cases the underlying mechanism is exactly what this lesson covered: a plain class marked #[Attribute], attached declaratively, and read back through ReflectionClass/Method/Property's getAttributes().
class Route { public function __construct(public string $path) {} } // forgot #[Attribute]
Without #[Attribute] on the class itself, PHP does not allow that class to be used as an attribute at all — writing #[Route('/x')] above something throws a fatal 'Attribute class "Route" not found' or 'is not an attribute class' error, because the class was never opted in.
Repeating an attribute on the same declaration is a compile-time error unless the attribute class explicitly opted into Attribute::IS_REPEATABLE — this must be set where Route is DECLARED, not where it's used.
$path = $method->getAttributes(Route::class)[0]->getArguments()['path']; // assumes named args used positionally as keys
$route = $method->getAttributes(Route::class)[0]->newInstance(); $path = $route->path;
getArguments() returns the raw arguments as written (which may be positional, named, or mixed, and doesn't apply the constructor's defaults for omitted parameters). newInstance() actually runs the constructor, correctly applying defaults and giving you real typed properties to read — far more robust than manually indexing into raw arguments.
What must be true for a class to be usable as an attribute (e.g. usable as #[MyThing(...)])? A. It must extend a built-in Attribute base class B. It must implement the AttributeInterface interface C. The class itself must have #[Attribute] attached to its own declaration D. Nothing special — any class can be used as an attribute automatically
C. The class itself must have #[Attribute] attached to its own declaration — PHP requires the class to explicitly opt in by having #[Attribute] attached to it. Without that, trying to use it as #[MyThing(...)] elsewhere throws an error, because the engine doesn't know that class is meant to serve as an attribute.
What does Attribute::IS_REPEATABLE control? A. Whether the attribute's constructor can be called multiple times B. Whether the same attribute class can be attached more than once to the same single declaration C. Whether the attribute automatically applies to all subclasses D. Whether Reflection caches the attribute's instantiated result
B. Whether the same attribute class can be attached more than once to the same single declaration — IS_REPEATABLE is purely about whether stacking the same attribute class multiple times on one declaration (e.g. two #[Route(...)] lines on one method) is legal. Without it, that's a compile-time error.
After $attrs = $reflection->getAttributes(Route::class);, what type are the elements of $attrs, and what do you call to get an actual usable Route object from one of them? A. They are already Route objects — no further call needed B. They are ReflectionAttribute objects; call ->newInstance() on one to construct the real Route object C. They are raw arrays of constructor arguments; call Route::fromArray() on one D. They are strings containing the attribute's source code; call eval() on one
B. They are ReflectionAttribute objects; call ->newInstance() on one to construct the real Route object — getAttributes() returns ReflectionAttribute wrapper objects, which describe the attribute without instantiating it. newInstance() is the explicit step that actually invokes the attribute class's real constructor and gives you back a genuine object of that class.
An attribute class is declared as #[Attribute(Attribute::TARGET_PROPERTY)] class Column {}. What happens if a developer writes #[Column] class User {} (attaching it to a class instead of a property)? A. It works fine — targets are only advisory hints, not enforced B. It silently does nothing when read via Reflection C. It is a compile-time error, since Column is restricted to TARGET_PROPERTY D. PHP automatically converts it to apply to all properties of the class
C. It is a compile-time error, since Column is restricted to TARGET_PROPERTY — Target restrictions passed to #[Attribute(...)] are enforced by the engine at parse/compile time, not just documentation — attaching a property-only attribute to a class declaration fails immediately with a fatal error, rather than being silently ignored or auto-applied.
Define an attribute #[Cacheable(ttl: int)] restricted to TARGET_METHOD (not repeatable), with a constructor taking an int $ttl defaulting to 60. Attach it to a method getExpensiveData() on a class ReportService with ttl: 300. Then write a function getCacheTtl(object $obj, string $method): ?int that uses Reflection to find the Cacheable attribute on that method and return its ttl, or null if the attribute isn't present.
Use new ReflectionMethod($obj, $method), then getAttributes(Cacheable::class); if the array is empty return null, otherwise newInstance() and read ->ttl.