Before PHP 8, the only way to attach metadata to a class or method was a @annotation inside a docblock — an untyped string that frameworks had to parse by hand. PHP 8’s attributes make metadata first-class, typed, and reflectable.

Defining an attribute
An attribute is just a class annotated with the built-in #[Attribute]:
#[Attribute(Attribute::TARGET_METHOD | Attribute::TARGET_CLASS)]
class Route {
public function __construct(
public string $path,
public string $method = 'GET'
) {}
}
Applying it
class UserController {
#[Route('/users', method: 'GET')]
public function index() { / ... / }
}
Reading it with reflection
Unlike docblocks, attributes are real objects you can instantiate and inspect:
$ref = new ReflectionClass(UserController::class);
foreach ($ref->getMethods() as $method) {
$attrs = $method->getAttributes(Route::class);
foreach ($attrs as $attr) {
$route = $attr->newInstance(); // Route instance
echo "{$route->method} {$route->path}\n";
}
}
Why it’s better than docblocks
- Typed & validated — wrong arguments are a fatal error at class load, not a silent parse miss.
- Discoverable — reflection gives you objects, not strings, so IDEs and static analyzers understand them.
- Framework-native — Symfony, Laravel, and PHPUnit all use attributes for routing, validation, and test definitions.
One caveat
Attributes are declarative data, not behavior. They don’t run anything by themselves — you still need code (usually reflection) to act on them. Keep the logic in a reader, and let the attribute stay a clean description of intent.