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.

PHP 8 attributes illustration

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.