PHPAttributes (Annotations)

Attributes (Annotations)

Frameworks have always needed a way to attach metadata to classes, methods, and properties — "this method handles GET requests to /users", "this property maps to a database column named created_at", "this class is a test case." Before PHP 8.0, there was no language feature for this, so the ecosystem invented one out of comments: Doctrine, Symfony, and others parsed specially formatted /** @Route("/users") */ docblocks at runtime using a separate annotation-reading library. It worked, but it was a workaround built on top of comments, which PHP itself has no obligation to preserve, validate, or even keep attached to the right symbol. PHP 8.0's attributes replace that workaround with real syntax the engine parses natively.

The #[Attribute] syntax

An attribute is written between #[ and ], placed directly above (or, for some targets, inline with) the class, method, property, parameter, or constant it describes. Syntactically, an attribute usage looks like a constructor call — you can pass arguments, including named arguments — because under the hood it is one.

Attributes in a Symfony-style controller

PHP
<?php
class UserController
{
    #[Route('/users', methods: ['GET'])]
    public function list(): array
    {
        return [];
    }

    #[Route('/users/{id}', methods: ['GET'])]
    public function show(int $id): array
    {
        return ['id' => $id];
    }
}

Compare that to the old docblock-comment equivalent — /** @Route("/users", methods=GET) */ — and the difference is immediately visible: the attribute version is checked by PHP's parser for basic syntax correctness, shows up in your IDE's autocomplete and "find usages," and survives a refactor (rename the Route class and every usage updates) in a way a string buried in a comment never could.

Defining your own attribute

Any class can become usable as an attribute by marking its declaration with #[Attribute]. The Attribute class itself accepts flags controlling where your attribute is allowed to be used and whether it can be repeated on the same target.

A custom Route attribute

PHP
<?php
#[Attribute(Attribute::TARGET_METHOD)]
class Route
{
    public function __construct(
        public readonly string $path,
        public readonly array $methods = ['GET'],
    ) {
    }
}

Declaring the Route class this way doesn't make anything happen automatically — an attribute, by itself, is inert metadata. PHP does not scan your codebase looking for #[Route] and wire up a router for you. Something has to explicitly ask, at runtime, "does this method have a Route attribute, and if so, what are its arguments?" That something is the Reflection API.

Reading attributes with Reflection

Every reflection object — ReflectionClass, ReflectionMethod, ReflectionProperty, and so on — exposes a getAttributes() method. It returns an array of ReflectionAttribute instances, each of which can produce a fully-constructed instance of the attribute class via newInstance().

Discovering routes with Reflection

PHP
<?php
$reflection = new ReflectionClass(UserController::class);

foreach ($reflection->getMethods() as $method) {
    $attributes = $method->getAttributes(Route::class);

    foreach ($attributes as $attribute) {
        $route = $attribute->newInstance();
        echo $method->getName() . ' => ' . $route->path
            . ' [' . implode(',', $route->methods) . "]\n";
    }
}
list => /users [GET]
show => /users/{id} [GET]

This is exactly the mechanism real routing components use internally: at startup (or during a cached compilation step), the framework reflects over every controller class, reads its Route attributes, and builds an internal routing table from the results — no annotation-parsing library required, because Reflection is built into the language.

Multiple attributes and repeatable attributes

A single symbol can carry more than one attribute, either as separate #[...] blocks or comma-separated inside one block. By default, PHP only allows one instance of a given attribute class per target; passing Attribute::IS_REPEATABLE as a flag lifts that restriction, which is exactly what you'd want for something like a controller method that should respond to a route under two different paths.

Combining attributes and allowing repeats

PHP
<?php
#[Attribute(Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE)]
class Route
{
    public function __construct(public readonly string $path) {}
}

class ProductController
{
    #[Route('/products')]
    #[Route('/items')]
    #[Deprecated]
    public function list(): array
    {
        return [];
    }
}
Real-world frameworks that use attributes

Attributes weren't designed for one narrow use case — the ecosystem adopted them quickly and broadly. Symfony uses them for routing, dependency injection configuration, and validation constraints. Doctrine ORM uses them to map entity properties to database columns, replacing its own older annotation format. PHPUnit, since version 10, offers attributes such as #[Test], #[DataProvider], and #[Group] as the modern alternative to @test-style docblock annotations. In every one of these cases, the underlying mechanism is the same Reflection-based reading shown above.

Attributes vs. docblock comments
A `/** @Route(...) */` docblock is just a comment — PHP discards comments before execution unless a library specifically parses them back out of the source. An attribute is real syntax attached to the compiled structure of your code, retrievable through Reflection with no extra parsing step. That's the core upgrade: metadata moved from "text a library hopes to find" to "structure the engine guarantees is there."
  • #[Attribute] marks a class as usable as an attribute; flags like TARGET_METHOD and IS_REPEATABLE control where and how often it can be applied.

  • An attribute usage such as #[Route('/users')] is inert on its own — nothing reads it unless your code (or a framework) calls getAttributes() via Reflection.

  • ReflectionAttribute::newInstance() constructs an actual instance of the attribute class from the arguments written at the usage site.

  • Symfony, Doctrine, and PHPUnit have all migrated their historical docblock-comment annotations to native attributes.

Tip
When designing your own attribute, keep its constructor arguments simple, serializable values (strings, ints, arrays, enum cases) rather than objects that need runtime state to construct — attribute arguments are evaluated once, at the point `newInstance()` is called, and are meant to describe static configuration, not carry live application state.