Defining Custom PHP Attributes and Reading Them via Reflection
AI generated
<?php
8.4
PHP 8.4 · Attributes · Reflection · Metaprogramming
Defining Custom PHP Attributes and Reading Them via Reflection
from docblock convention to a native language feature

Custom PHP attributes replace docblock annotations with a compiler-parsed, type-safe syntax: a class marked with #[Attribute] defines the structure, Attribute::TARGET flags restrict the allowed target, and a ReflectionClass reads the data at runtime, all without a fragile string parser for comments.

13 min read Attributes · Reflection · TARGET Flags · Validation PHP 8.0 · 8.1 · 8.2 · 8.3 · 8.4

1. Why custom PHP attributes: from annotations to native syntax

Before PHP 8.0, metadata on classes, methods, and properties was carried almost exclusively through docblock annotations like @Route("/api/orders"). From the PHP parser's point of view, these annotations were plain comments, with no syntax checking whatsoever. Frameworks had to write their own parsers that split comment text apart with regular expressions, and a typo surfaced only at runtime, never while writing the code. Custom PHP attributes solve exactly this problem.

Since PHP 8.0, attributes are part of the language syntax and are parsed by the compiler itself, not by an external regex parser. A typo in attribute syntax leads to a real parse error, visible immediately in the IDE, not only at execution time. That turns custom PHP attributes from a plain comment into a full-fledged, type-safe language construct that follows the same rules for types, constructors, and visibility as any other class.

The practical benefit shows up wherever configuration should live close to the code: routing, validation rules, serialization hints, or access control. Instead of a separate YAML or XML file, the information sits directly above the method or property it concerns, readable by humans and evaluable by machines via reflection, without any additional parsing library.

2. Defining an attribute class with #[Attribute]

The first step toward custom PHP attributes is an ordinary class that is itself marked with #[Attribute]. This marker tells PHP that instances of this class are allowed to be used as an attribute above other code. Without this marker, PHP throws an Error when the class is used as an attribute, since not every class is meant to serve this purpose.

The attribute class itself follows no special rules beyond ordinary PHP classes: it has a constructor, typed properties, and can even have its own methods. Typically, though, an attribute class is deliberately kept simple, as a pure data container without business logic, since the actual processing happens later via reflection, not inside the attribute itself.


<?php

declare(strict_types=1);

#[Attribute]
final class Route
{
    // Plain data container: no business logic inside the attribute itself
    public function __construct(
        public readonly string $path,
        public readonly string $method = 'GET',
    ) {
    }
}

final class OrderController
{
    #[Route('/api/orders', method: 'POST')]
    public function create(): void
    {
        // Handler logic
    }

    #[Route('/api/orders/{id}')]
    public function show(): void
    {
        // Handler logic
    }
}

3. Restricting the target with Attribute::TARGET and combining flags

Without further specification, an attribute may be placed on any language element: class, method, property, parameter, class constant, or function. That is rarely desired, since a routing attribute makes no sense on a property. The constant Attribute::TARGET_METHOD and its siblings TARGET_CLASS, TARGET_PROPERTY, TARGET_PARAMETER, TARGET_CLASS_CONSTANT, and TARGET_FUNCTION precisely restrict the allowed target of a custom PHP attribute.

Multiple targets can be combined with the bitwise or operator, such as Attribute::TARGET_METHOD | Attribute::TARGET_FUNCTION, when an attribute should be allowed on both methods and free functions. If an attribute is accidentally used at a disallowed location, PHP throws an Error when instantiating it via reflection, not already when parsing the file, which is worth noting when debugging.

The second parameter of the #[Attribute] constructor itself is a bitmask made of TARGET constants. If it is missing, Attribute::TARGET_ALL applies implicitly, meaning any possible target. For custom PHP attributes used in larger teams, an explicit target restriction is almost always sensible, because it structurally prevents misuse instead of merely relying on documentation.


<?php

declare(strict_types=1);

// Restricting the target prevents misuse on the wrong language element
#[Attribute(Attribute::TARGET_METHOD | Attribute::TARGET_FUNCTION)]
final class Route
{
    public function __construct(
        public readonly string $path,
        public readonly string $method = 'GET',
    ) {
    }
}

#[Attribute(Attribute::TARGET_PROPERTY | Attribute::TARGET_PARAMETER)]
final class Sensitive
{
    public function __construct(
        public readonly string $reason = 'PII',
    ) {
    }
}

final class Customer
{
    public function __construct(
        #[Sensitive('contains payment data')]
        public readonly string $iban,
    ) {
    }
}

4. Reading PHP attributes via reflection

An attribute without evaluation remains a pure declaration with no effect. The central mechanism to actually make custom PHP attributes useful is the reflection API. ReflectionMethod::getAttributes(), ReflectionClass::getAttributes(), and ReflectionProperty::getAttributes() each return an array of ReflectionAttribute objects for the respective language element.

Each ReflectionAttribute object offers getName() for the fully qualified class name of the attribute, getArguments() for the raw, unprocessed constructor arguments, and, most importantly, newInstance(), which creates a real instance of the attribute class, including full type checking through the regular constructor. This instantiation is the point where an attribute turns from pure metadata into a usable object.


<?php

declare(strict_types=1);

final class Router
{
    // Scans a controller class and builds a route table from #[Route] attributes
    public function collectRoutes(string $controllerClass): array
    {
        $reflectionClass = new ReflectionClass($controllerClass);
        $routes = [];

        foreach ($reflectionClass->getMethods() as $method) {
            foreach ($method->getAttributes(Route::class) as $attribute) {
                /** @var Route $route */
                $route = $attribute->newInstance();
                $routes[] = [
                    'path' => $route->path,
                    'method' => $route->method,
                    'handler' => [$controllerClass, $method->getName()],
                ];
            }
        }

        return $routes;
    }
}

$router = new Router();
$routes = $router->collectRoutes(OrderController::class);
print_r($routes);

5. Constructor arguments, named parameters, and default values

Attributes use the same constructor call syntax as ordinary object instantiation, including named arguments. #[Route('/api/orders', method: 'POST')] shows how positional and named arguments can be mixed, exactly like a normal new Route(...) call. This consistency is one of the reasons custom PHP attributes fit so naturally into existing PHP code: there is no separate syntax to learn.

Default values in the attribute class's constructor work unchanged as well. A parameter string $method = 'GET' allows the attribute to be used without that parameter, as long as the default fits. Enums as constructor parameters have also been allowed as attribute arguments since PHP 8.1, which brings extra type safety over a raw string: #[Route('/api/orders', method: HttpMethod::Post)] makes invalid method identifiers impossible in the first place.

6. Building a custom validation system on top of PHP attributes

A particularly illustrative practical example of custom PHP attributes is a simple validation system for data transfer objects. Instead of maintaining validation rules in a separate configuration file, they sit directly above the relevant property, as #[NotBlank] or #[Length(min: 3, max: 50)]. A generic validator reads every property of a class instance via reflection, checks its attributes, and collects violations into an error list.

This architecture cleanly separates declaration from execution: attribute classes like NotBlank and Length only define which rule applies and with what parameters, while a separate ValidationRule interface with a validate(mixed $value): bool method implements the actual check. That keeps each rule independently testable, without ever having to touch the validator itself.


<?php

declare(strict_types=1);

interface ValidationRule
{
    public function validate(mixed $value): bool;
    public function message(string $property): string;
}

#[Attribute(Attribute::TARGET_PROPERTY | Attribute::IS_REPEATABLE)]
final class NotBlank implements ValidationRule
{
    public function validate(mixed $value): bool
    {
        return is_string($value) && trim($value) !== '';
    }

    public function message(string $property): string
    {
        return "{$property} must not be blank.";
    }
}

#[Attribute(Attribute::TARGET_PROPERTY)]
final class Length implements ValidationRule
{
    public function __construct(
        private readonly int $min,
        private readonly int $max,
    ) {
    }

    public function validate(mixed $value): bool
    {
        $length = is_string($value) ? mb_strlen($value) : 0;

        return $length >= $this->min && $length <= $this->max;
    }

    public function message(string $property): string
    {
        return "{$property} must be between {$this->min} and {$this->max} characters.";
    }
}

final class Validator
{
    // Reads validation rules from attributes and collects violations
    public function validate(object $target): array
    {
        $errors = [];
        $reflectionClass = new ReflectionClass($target);

        foreach ($reflectionClass->getProperties() as $property) {
            $value = $property->getValue($target);

            foreach ($property->getAttributes(ValidationRule::class, ReflectionAttribute::IS_INSTANCEOF) as $attribute) {
                /** @var ValidationRule $rule */
                $rule = $attribute->newInstance();

                if (!$rule->validate($value)) {
                    $errors[] = $rule->message($property->getName());
                }
            }
        }

        return $errors;
    }
}

7. Processing repeatable attributes with IS_REPEATABLE

By default, an attribute may only be placed once on the same language element. A second attempt to set the same attribute a second time at the same spot results in an error. For cases where several independent rules of the same type should apply, such as multiple permissions or multiple validation conditions, the flag Attribute::IS_REPEATABLE allows the same custom PHP attribute to be repeated any number of times.

Reading attributes does not change at the API level because of this: getAttributes() always returns an array anyway, regardless of whether the attribute is repeatable. The difference lies solely in the fact that without IS_REPEATABLE, at most one element can occur in that array, while with the flag, any number of occurrences is possible, each with potentially different constructor arguments.


<?php

declare(strict_types=1);

#[Attribute(Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE)]
final class RequiresPermission
{
    public function __construct(
        public readonly string $permission,
    ) {
    }
}

final class InvoiceController
{
    // Repeated attribute: each occurrence is a separate ReflectionAttribute
    #[RequiresPermission('invoice.view')]
    #[RequiresPermission('invoice.export')]
    public function export(): void
    {
        // Handler logic
    }
}

$method = new ReflectionMethod(InvoiceController::class, 'export');
$permissions = array_map(
    static fn (ReflectionAttribute $attribute): string => $attribute->newInstance()->permission,
    $method->getAttributes(RequiresPermission::class),
);

print_r($permissions); // ['invoice.view', 'invoice.export']

8. PHP attributes vs. interfaces and docblocks: when to use what

Not every piece of metadata belongs in an attribute. When behavior needs to be enforced at compile time through the type checker, such as a class being required to implement a specific method, an interface is the right choice, since an attribute enforces nothing, it only describes. Custom PHP attributes, by contrast, suit optional, declarative extra information that an external component like a router or validator evaluates at runtime.

Docblocks remain useful for purely documentary purposes with no machine evaluation, such as explanatory descriptions or @see references, as well as for PHPStan-specific type annotations like @var array<int, string> that do not yet exist in the native type system. But as soon as real runtime logic depends on the metadata, such as a router that actually registers paths, an attribute is structurally superior to a docblock, because it gets parsed and type-checked, instead of existing as a free-text comment.

Task Unsafe / cumbersome Recommended with PHP attributes Benefit
Attach metadata to a method PHPDoc comment like @Route(...) #[Route(...)] Parsed by the compiler, no regex needed
Multiple rules on one property Several @Annotation lines in the comment Attribute with IS_REPEATABLE applied multiple times Native, no external library needed
Reading attribute data Custom docblock parser with regex getAttributes() via reflection Robust, IDE-supported, no custom parser
Restricting the target Convention or a comment in the docs Attribute::TARGET_METHOD flag Enforced by the compiler, not just documented
Creating an instance from an attribute Manual string parsing of arguments $attribute->newInstance() Type-safe, uses the regular constructor

9. Reflection performance overhead and caching strategies

Reflection is not free: every call to getAttributes() and every instantiation via newInstance() costs measurable time, noticeably more than a direct method call. For custom PHP attributes that get re-read on every single request, such as during every routing operation, this overhead adds up noticeably in production applications with high request frequency.

The usual solution is caching the evaluated attribute data, not the reflection objects themselves. A router typically builds the route table from all controllers once at application startup or deployment and serializes the result into a cache file, for example as a PHP array or a serialized object. At runtime for every single request, only that cache is then read, reflection runs only once during the build step, never per request.

For development environments, this cache is usually undesirable, though, because code changes should be visible immediately without a manual cache clear. A typical pattern is therefore to enable the attribute cache only when an environment variable like APP_ENV=production is set, and to read fresh via reflection on every request in development instead.

10. Summary

Custom PHP attributes replace fragile docblock annotations with a compiler-parsed, type-safe syntax. A class is itself marked with #[Attribute], optionally restricted to a specific language element with TARGET flags, and opened up for repeated use with IS_REPEATABLE. Reflection methods like getAttributes() and newInstance() read the data at runtime and produce real, type-checked instances of the attribute class.

Attributes suit optional, declarative metadata that an external component evaluates, while interfaces remain the right choice for enforced behavior. Since every reflection evaluation carries a runtime cost, production environments with high request frequency benefit from caching the evaluated attribute data, built once at deployment instead of freshly on every request.

Custom PHP Attributes and Reflection, the Essentials

Defining the attribute class

#[Attribute] on an ordinary class. Constructor arguments like any other class, usually as a pure data container.

Restricting the target

Attribute::TARGET_METHOD, TARGET_PROPERTY and more, combinable with the bitwise or operator.

Reading via reflection

getAttributes() returns ReflectionAttribute objects, newInstance() produces a type-checked instance from them.

Performance

Reflection has a runtime cost. Cache evaluated attribute data in production, read fresh in development.

11. FAQ: Defining and Evaluating Custom PHP Attributes

1Can PHP attributes contain their own logic?
Technically yes, but not common. Attributes should stay pure data containers, processing belongs in a separate component.
2Are attributes evaluated automatically?
No, they must be actively read via reflection through getAttributes() and newInstance().
3Difference from docblock annotations?
Attributes are native syntax, parsed and type-checked by the compiler. Docblocks were plain comments without syntax checking.
4Use an attribute more than once at one spot?
Only with the flag Attribute::IS_REPEATABLE. Without it, a second use results in an error.
5What TARGET flags exist?
TARGET_CLASS, TARGET_FUNCTION, TARGET_METHOD, TARGET_PROPERTY, TARGET_CLASS_CONSTANT, TARGET_PARAMETER, TARGET_ALL, combinable via or operator.
6newInstance() vs. getArguments()?
getArguments() returns raw data as an array, newInstance() calls the real constructor and returns a type-checked instance.
7Can promoted properties get attributes?
Yes, directly before the promoted constructor parameter, exactly like classically declared properties.
8Are enums possible as attribute arguments?
Yes, since PHP 8.1. That brings extra type safety over raw strings.
9How expensive is reflection at runtime?
Measurably more expensive than a direct method call. At high frequency, caching the evaluated data pays off.
10Use an interface instead of an attribute?
When behavior must be enforced by the type system. Attributes only describe, they enforce nothing.

Mironsoft

PHP architecture, code quality, and Magento development

Want to introduce custom PHP attributes cleanly in your own project?

We design custom PHP attributes for routing, validation, and access control, including reflection-based evaluation with a caching strategy for production environments and full PHPStan coverage.

Attribute design

Designing custom attribute classes with matching TARGET flags

Reflection systems

Implementing validation, routing, or serialization based on attributes

Performance tuning

Caching strategies for reflection-heavy attribute evaluation in production