PHP Attributes
PHP attributes, introduced in PHP 8.0, are a structured way to attach machine-readable metadata to classes, methods, properties, functions, parameters, and constants using the #[...] syntax. Before attributes, developers relied on docblock comments like /** @Route("/users") */ parsed with string-matching libraries — attributes replace that convention with real, native PHP syntax that the engine parses and validates at compile time. They matter because they power the metadata-driven APIs behind modern frameworks: routing, dependency injection, validation, ORM mapping, and testing all lean on attributes to describe behavior declaratively instead of scattering configuration files everywhere.
Overview / How Attributes Work
An attribute is just a class. Any ordinary class can act as an attribute — there is nothing magical about it except that you mark it with the built-in #[Attribute] attribute so PHP knows it is meant to be used this way, and so PHP can enforce rules about where it may be applied. You then attach an instance of that class to a piece of code by writing #[YourAttributeName(arguments)] directly above (or before, on the same declaration line) the target.
Crucially, attributes are inert by default. Writing #[Route('/users')] above a method does not make PHP register a route. Nothing runs automatically. Attributes are stored as compiled metadata that PHP attaches to the declaration; it is entirely up to your code — almost always via the Reflection API — to go looking for that metadata and act on it. This is a key mental shift from many other languages’ “decorators,” which often DO wrap or modify behavior automatically. In PHP, an attribute is pure data until something reads it.
Internally, when the Zend Engine compiles a file, it parses each #[...] block into an internal representation (an array of name + constant-expression arguments) and stores it on the corresponding zend_class_entry, zend_function, or property/parameter structure. At runtime, the Reflection API exposes this stored metadata through ReflectionAttribute objects, which you can inspect (get the attribute’s name and raw arguments) or instantiate (build a real object of the attribute class by calling its constructor with the supplied arguments).
Where attributes can be applied
- Classes, interfaces, traits, and enums
- Methods and functions
- Properties (including promoted constructor properties)
- Function/method parameters
- Class constants (since PHP 8.3)
You can restrict which of these targets an attribute is allowed on by passing bitmask flags to #[Attribute(...)] on the attribute class itself, and you can allow the same attribute to be repeated multiple times on one target with Attribute::IS_REPEATABLE.
Syntax
#[AttributeName]
#[AttributeName(arg1, arg2)]
#[AttributeName(name: value)]
#[FirstAttribute, SecondAttribute(1, 2)]
function target() {}
| Part | Meaning |
|---|---|
#[...] |
Attribute syntax delimiters; everything between them is one attribute group |
AttributeName |
The class name of the attribute (must exist and, conventionally, be marked with #[Attribute]) |
(arg1, arg2) |
Constructor arguments, passed positionally or by name, exactly like a normal constructor call |
| Comma-separated list | Multiple attributes can share one #[...] block, or you can stack separate #[...] blocks on their own lines |
| Arguments | Must be constant expressions (literals, const values, enum cases) — you cannot call a function or reference a variable |
Declaring your own attribute class looks like this:
#[Attribute(Attribute::TARGET_METHOD | Attribute::TARGET_CLASS)]
class MyAttribute
{
public function __construct(public string $value) {}
}
The flags you can pass to Attribute::__construct() are TARGET_CLASS, TARGET_FUNCTION, TARGET_METHOD, TARGET_PROPERTY, TARGET_CLASS_CONSTANT, TARGET_PARAMETER, TARGET_ALL (default), and IS_REPEATABLE (combined with a bitwise OR | against a target).
Examples
Example 1: A simple routing attribute read via Reflection
<?php
#[Attribute]
class Route
{
public function __construct(
public string $path,
public string $method = 'GET'
) {}
}
class UserController
{
#[Route('/users', method: 'GET')]
public function index(): string
{
return "List of users";
}
#[Route('/users/{id}', method: 'GET')]
public function show(int $id): string
{
return "User #{$id}";
}
}
$reflection = new ReflectionClass(UserController::class);
foreach ($reflection->getMethods() as $method) {
$attributes = $method->getAttributes(Route::class);
foreach ($attributes as $attribute) {
$route = $attribute->newInstance();
echo "{$route->method} {$route->path} -> {$method->getName()}()\n";
}
}
Output:
GET /users -> index()
GET /users/{id} -> show()
Here Route is a plain class marked with #[Attribute]. Nothing about routing happens automatically — the script manually walks every method of UserController with ReflectionClass, asks each method for its Route attributes via getAttributes(), and calls newInstance() to build a real Route object from the stored constructor arguments. This is exactly the pattern real routers use.
Example 2: Repeatable attributes for middleware
<?php
#[Attribute(Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE)]
class Middleware
{
public function __construct(public string $name) {}
}
class ApiController
{
#[Middleware('auth')]
#[Middleware('throttle')]
public function store(): void
{
echo "Storing resource\n";
}
}
$method = new ReflectionMethod(ApiController::class, 'store');
$middlewares = $method->getAttributes(Middleware::class);
foreach ($middlewares as $attribute) {
$instance = $attribute->newInstance();
echo "Applying middleware: {$instance->name}\n";
}
Output:
Applying middleware: auth
Applying middleware: throttle
Because Middleware was declared with Attribute::IS_REPEATABLE, PHP allows it to be stacked twice on the same method. Without that flag, applying the same non-repeatable attribute more than once to one target throws a fatal Error at compile time. getAttributes() returns one ReflectionAttribute per occurrence, in the order they were declared.
Example 3: A realistic validation system built on property attributes
<?php
#[Attribute(Attribute::TARGET_PROPERTY)]
class Required
{
}
#[Attribute(Attribute::TARGET_PROPERTY)]
class MaxLength
{
public function __construct(public int $length) {}
}
class RegistrationForm
{
#[Required]
#[MaxLength(50)]
public string $username = '';
#[Required]
public string $email = '';
}
function validate(object $object): array
{
$errors = [];
$reflection = new ReflectionClass($object);
foreach ($reflection->getProperties() as $property) {
$value = $property->getValue($object);
$name = $property->getName();
foreach ($property->getAttributes(Required::class) as $attr) {
if ($value === '') {
$errors[] = "{$name} is required";
}
}
foreach ($property->getAttributes(MaxLength::class) as $attr) {
$maxLength = $attr->newInstance();
if (strlen($value) > $maxLength->length) {
$errors[] = "{$name} exceeds max length of {$maxLength->length}";
}
}
}
return $errors;
}
$form = new RegistrationForm();
$form->username = str_repeat('a', 60);
$form->email = '';
$errors = validate($form);
foreach ($errors as $error) {
echo $error . "\n";
}
Output:
username exceeds max length of 50
email is required
This mirrors how real frameworks like Symfony’s Validator build declarative rules: each property carries one or more attribute-based constraints, and a generic validate() function loops over every property, reads whichever constraint attributes are present, instantiates them, and applies their rules against the current value. Adding a new rule is as simple as writing a new attribute class — no changes needed to the properties that don’t use it.
Under the Hood
When PHP compiles a script, the attribute syntax is parsed into an AST node containing the attribute’s class name and its argument expressions, but the arguments are not evaluated yet. This is why attribute arguments must be constant expressions — they need to be resolvable without running arbitrary code. The parsed metadata is attached to the relevant zend_class_entry or zend_function structure and, importantly, costs nothing at runtime unless something asks for it.
When your code calls ReflectionClass::getAttributes() (or the equivalent on a method, property, or parameter reflector), PHP returns an array of ReflectionAttribute objects — lightweight wrappers around the stored metadata. At this point the arguments still haven’t been evaluated. Only when you call ->newInstance() does PHP evaluate the constant expressions and actually invoke the attribute class’s constructor, producing a real object. You can also call ->getArguments() to get the raw argument array without instantiating the class, and ->getName() to get the attribute’s class name as a string without triggering autoloading.
This lazy design matters for performance: a framework can scan thousands of classes for attributes cheaply (just metadata lookups) and only pay the cost of instantiating attribute objects for the small subset it actually needs, such as the controller matching the current request’s URL.
Common Mistakes
Mistake 1: Assuming an attribute does something by itself
<?php
#[Attribute]
class Route
{
public function __construct(public string $path) {}
}
class HomeController
{
#[Route('/home')]
public function index(): string
{
return "Home page";
}
}
$controller = new HomeController();
echo $controller->index();
// No router was ever registered — the #[Route] attribute is
// stored metadata that nothing in this script reads.
This code runs fine and prints “Home page”, but the Route attribute has zero effect on how the application behaves — it’s just sitting there as metadata. Beginners often expect attributes to trigger behavior automatically, the way annotations do in some other ecosystems. The fix is to remember that you (or a framework) must explicitly use ReflectionClass/ReflectionMethod to read and act on the attribute, as shown in Example 1.
Mistake 2: Applying an attribute to the wrong target
<?php
#[Attribute(Attribute::TARGET_METHOD)]
class Route
{
public function __construct(public string $path) {}
}
#[Route('/users')]
class UserController
{
}
The Route attribute was restricted to TARGET_METHOD only, but it was applied to a class declaration. This parses fine but throws a fatal Error at runtime the moment PHP resolves the attribute (“Attribute \”Route\” cannot target class (allowed targets: method)”). The fix is to either apply the attribute to a method instead, or widen the allowed targets: #[Attribute(Attribute::TARGET_METHOD | Attribute::TARGET_CLASS)].
Mistake 3: Passing non-constant expressions as arguments
function getDefaultPath(): string
{
return '/home';
}
#[Route(getDefaultPath())]
class HomeController
{
}
Attribute arguments are evaluated as constant expressions at compile time, so you cannot call a function, reference a variable, or use $this inside them. This example fails to compile with an error about invalid constant expressions. The fix is to use a literal, a const, or an enum case instead: #[Route('/home')] or #[Route(self::DEFAULT_PATH)].
Best Practices
- Always mark your custom attribute classes with
#[Attribute]and restrict their targets with the appropriate flags — this turns misuse into a clear fatal error instead of silent misbehavior. - Use named arguments (
#[Route(path: '/users', method: 'POST')]) for attributes with several parameters — it keeps call sites readable and order-independent. - Keep attribute classes small and free of side effects in their constructors; they should describe data, not perform actions, since instantiation timing is controlled by whatever reads them.
- Cache the results of expensive reflection scans (e.g. scanning every controller for routes) rather than re-scanning on every request in production.
- Prefer attributes over array-based configuration or docblock annotations for new code — they’re validated by the parser, support autocompletion in IDEs, and can’t drift out of sync with a typo the way free-text comments can.
- Use
IS_REPEATABLEonly when it’s genuinely valid to apply the same attribute multiple times (like stacking several middleware entries); otherwise leave it off so duplicate application is caught as an error. - Combine attributes with enums and readonly properties for immutable, type-safe metadata objects, e.g.
#[Status(Level::Warning)].
Practice Exercises
- Define a
#[Deprecated]attribute (with an optionalstring $reasonconstructor parameter) restricted to methods. Apply it to two methods of a sample class, then write a script usingReflectionClassthat prints a warning line for every deprecated method it finds, including the reason if one was given. - Create a repeatable
#[Tag(string $name)]attribute and apply it three times to a single class. Write code that collects all tag names into an array and prints them as a comma-separated string. - Build a tiny “dependency container” exercise: define an
#[Inject]attribute restricted to constructor parameters. Apply it to a class constructor parameter, then useReflectionMethod::getParameters()andgetAttributes()to detect which parameters are marked for injection, printing their names and declared types.
Summary
- Attributes use the
#[AttributeName(args)]syntax to attach structured metadata to classes, methods, properties, parameters, functions, and (as of PHP 8.3) class constants. - An attribute is just a class, conventionally marked with
#[Attribute]; that marker also lets you restrict valid targets and allow repetition viaIS_REPEATABLE. - Attributes do nothing on their own — they are inert metadata read at runtime through
ReflectionAttributeobjects obtained fromgetAttributes(). ReflectionAttribute::newInstance()lazily evaluates the constant-expression arguments and constructs a real object of the attribute class.- Attribute arguments must be compile-time constant expressions — no function calls, no variables.
- Attributes replace docblock annotation conventions with real, parser-validated PHP syntax, forming the backbone of modern routing, validation, and dependency-injection systems.
