Attributes

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against PHP 8.5, checked on 2026-10-05. Attributes (8.0) add structured, machine-readable metadata to declarations: classes, functions, methods, properties, class constants, parameters and, since 8.5, global constants. PHP itself ignores user attributes until code asks for them through Reflection; frameworks use them for routing, validation, ORM mapping, dependency injection and test configuration. Built-in attributes, on the other hand, change how the engine treats the declaration.

Syntax

An attribute is written as #[Name] or #[Name(arguments)] directly before the declaration. The name is resolved like a class name (imports apply), arguments are constant expressions and can be named, and several attributes can be listed in one #[…] group or in separate groups.

<?php

declare(strict_types=1);

namespace Library\Http;

use Attribute;

#[Attribute(Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE)]
final class Route
{
    public function __construct(
        public string $path,
        public string $method = 'GET',
        public ?string $name = null,
    ) {}
}

#[Attribute(Attribute::TARGET_CLASS)]
final class Controller {}

#[Controller]
final class BookController
{
    #[Route('/books')]
    #[Route('/catalogue', name: 'catalogue')]          // repeated, thanks to IS_REPEATABLE
    public function index(): string { return 'all books'; }

    #[Route(path: '/books', method: 'POST'), Route(path: '/books/new', method: 'POST')]   // two in one group
    public function store(): string { return 'stored'; }
}

echo "attributes declared\n";

Before PHP 8.0, # started a comment, so #[Route] on a line by itself is silently ignored by PHP 7 — which made the syntax backward compatible for annotation-style metadata.

Declaring attribute classes

Any class can be used as an attribute once it is marked with the #[Attribute] attribute itself. Its constructor receives the arguments. Flags on #[Attribute] restrict where it can appear and whether it can be repeated on one declaration:

Flag Allows the attribute on

Attribute::TARGET_CLASS

classes, interfaces, traits, enums

Attribute::TARGET_FUNCTION

functions and closures

Attribute::TARGET_METHOD

methods

Attribute::TARGET_PROPERTY

properties

Attribute::TARGET_CLASS_CONSTANT

class constants and enum cases

Attribute::TARGET_PARAMETER

function and method parameters

Attribute::TARGET_CONSTANT

global constants declared with const (8.5)

Attribute::TARGET_ALL

everywhere (the default)

Attribute::IS_REPEATABLE

the same declaration more than once

Targets and repetition are validated only when the attribute is instantiated through Reflection, not at compile time (except for PHP’s own attributes).

Reading attributes with Reflection

getAttributes() on any reflector (ReflectionClass, ReflectionMethod, ReflectionProperty, ReflectionParameter, ReflectionClassConstant, ReflectionFunction, ReflectionConstant) returns ReflectionAttribute objects, optionally filtered by class name (with ReflectionAttribute::IS_INSTANCEOF to include subclasses). getName() and getArguments() inspect them without instantiating; newInstance() creates the attribute object, running its constructor and validating the target.

A tiny router built on the Route attribute above:

<?php

declare(strict_types=1);

#[Attribute(Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE)]
final class Route
{
    public function __construct(public string $path, public string $method = 'GET') {}
}

final class BookController
{
    #[Route('/books/{isbn}')]
    public function show(string $isbn): string { return "book {$isbn}"; }

    #[Route('/books', method: 'POST')]
    public function store(): string { return 'stored'; }
}

/** @return array<string, array{class-string, string}> "METHOD path" => [class, method] */
function discoverRoutes(string ...$controllers): array
{
    $routes = [];
    foreach ($controllers as $class) {
        foreach ((new ReflectionClass($class))->getMethods() as $method) {
            foreach ($method->getAttributes(Route::class) as $attribute) {
                $route = $attribute->newInstance();
                $routes["{$route->method} {$route->path}"] = [$class, $method->getName()];
            }
        }
    }
    return $routes;
}

foreach (discoverRoutes(BookController::class) as $key => [$class, $method]) {
    echo "{$key} -> {$class}::{$method}()\n";
}

// Inspecting without instantiating
$attr = (new ReflectionMethod(BookController::class, 'store'))->getAttributes()[0];
echo $attr->getName(), ' ', json_encode($attr->getArguments()), "\n";   // Route {"0":"\/books","method":"POST"}
GET /books/{isbn} -> BookController::show()
POST /books -> BookController::store()
Route {"0":"\/books","method":"POST"}

Reflection is slow compared with plain method calls, so frameworks read attributes once and cache the result (Laravel and Symfony compile routes into cached files).

Validation errors at instantiation

<?php

declare(strict_types=1);

#[Attribute(Attribute::TARGET_CLASS)]
final class Entity {}

final class Misuse
{
    #[Entity]                    // wrong target: not detected until newInstance()
    public function save(): void {}
}

try {
    (new ReflectionMethod(Misuse::class, 'save'))->getAttributes()[0]->newInstance();
} catch (Error $e) {
    echo $e->getMessage(), "\n";   // Attribute "Entity" cannot target method (allowed targets: class)
}

The predefined attributes

Attribute Since Effect

#[\Attribute]

8.0

marks a class as an attribute class

#[\ReturnTypeWillChange]

8.1

silences the deprecation for an overriding internal method without a tentative return type (migration aid)

#[\AllowDynamicProperties]

8.2

allows dynamic properties on a class (they are deprecated otherwise)

#[\SensitiveParameter]

8.2

replaces a parameter’s value with a SensitiveParameterValue object in stack traces

#[\Override]

8.3

compile-time check that a method overrides a parent or interface method (properties in 8.5)

#[\Deprecated]

8.4

makes calling a function or method, or reading a class constant, emit E_USER_DEPRECATED

#[\NoDiscard]

8.5

warns when a function’s return value is not used (see Functions)

#[\DelayedTargetValidation]

8.5

defers target errors of internal attributes on the same declaration to newInstance(), for forward compatibility

<?php

declare(strict_types=1);

final class Catalogue
{
    #[\Deprecated('use Catalogue::LOAN_DAYS instead', since: '2.0')]
    public const DAYS = 21;
    public const LOAN_DAYS = 21;

    #[\Deprecated(message: 'use lend() instead', since: '2.0')]
    public function borrow(string $isbn): string { return $this->lend($isbn); }

    public function lend(string $isbn): string { return "lent {$isbn}"; }
}

$c = new Catalogue();
echo $c->borrow('978-0-00-000001-1'), "\n";
// Deprecated: Method Catalogue::borrow() is deprecated since 2.0, use lend() instead
echo Catalogue::DAYS, "\n";
// Deprecated: Constant Catalogue::DAYS is deprecated since 2.0, use Catalogue::LOAN_DAYS instead

function login(string $user, #[\SensitiveParameter] string $password): never
{
    throw new RuntimeException('Invalid credentials');
}

try {
    login('ada', 'correct horse battery staple');
} catch (RuntimeException $e) {
    $arg = $e->getTrace()[0]['args'][1] ?? null;
    var_dump($arg instanceof SensitiveParameterValue);       // bool(true): the password is not in the trace
    var_dump(str_contains($e->getTraceAsString(), 'staple')); // bool(false)
}

#[\Deprecated] replaces the @deprecated PHPDoc tag when callers should be told at runtime; the notice includes the message and the version. Use #[\SensitiveParameter] on every password, token, key or secret parameter, so that logs and error pages never contain them (PHP’s own functions such as password_hash() already use it).

Stack traces include arguments only when zend.exception_ignore_args is Off; production php.ini sets it to On, which hides all arguments. #[\SensitiveParameter] protects development and staging setups and logs too.

DelayedTargetValidation and forward compatibility

Internal attributes are validated at compile time: #[\Override] on a class constant is a compile error in PHP 8.5, because only PHP 8.6 allows it there. #[\DelayedTargetValidation] (8.5) on the same declaration postpones that error until the attribute is instantiated through Reflection, so a library can already use the 8.6 form and still load on 8.5:

<?php

declare(strict_types=1);

class Base { public const NAME = 'base'; }

final class Child extends Base
{
    #[\DelayedTargetValidation]
    #[\Override]                       // Override on constants: only valid from PHP 8.6
    public const NAME = 'child';
}

echo Child::NAME, "\n";                // child -- loads fine on 8.5

try {
    (new ReflectionClassConstant(Child::class, 'NAME'))->getAttributes(Override::class)[0]->newInstance();
} catch (Error $e) {
    echo $e->getMessage(), "\n";       // on 8.5: Attribute "Override" cannot target class constant (allowed targets: method, property)
}

Attributes on constants (PHP 8.5)

Since PHP 8.5, global constants declared with const (not define()) can carry attributes, and #[\Deprecated] works on them. They are read with ReflectionConstant::getAttributes() (ReflectionConstant itself is new in 8.4).

<?php

declare(strict_types=1);

#[\Deprecated('use LOAN_DAYS')]
const DEFAULT_LOAN_DAYS = 21;

const LOAN_DAYS = 21;

echo DEFAULT_LOAN_DAYS, "\n";          // Deprecated: Constant DEFAULT_LOAN_DAYS is deprecated, use LOAN_DAYS
var_dump((new ReflectionConstant('DEFAULT_LOAN_DAYS'))->getAttributes()[0]->getName());   // string(10) "Deprecated"

A validator: subclass filtering, closures and inheritance

Validation libraries are the classic use of attributes: rules sit on properties, and one function reads them. Three details make such a reader work. A common base class lets getAttributes() find every rule with ReflectionAttribute::IS_INSTANCEOF. Since PHP 8.5, an attribute argument can be a static closure, so a one-off rule needs no class. And attributes are not inherited, which matters as soon as a class is extended:

<?php

declare(strict_types=1);

abstract class Constraint                                     // the base class all rules extend
{
    abstract public function check(mixed $value): ?string;    // null = valid, else a message
}

#[Attribute(Attribute::TARGET_PROPERTY | Attribute::TARGET_PARAMETER)]
final class Length extends Constraint
{
    public function __construct(public int $max) {}

    public function check(mixed $value): ?string
    {
        return mb_strlen($value) <= $this->max ? null : "at most {$this->max} characters";
    }
}

#[Attribute(Attribute::TARGET_PROPERTY | Attribute::TARGET_PARAMETER | Attribute::IS_REPEATABLE)]
final class Satisfies extends Constraint
{
    public function __construct(public Closure $rule, public string $message) {}

    public function check(mixed $value): ?string
    {
        return ($this->rule)($value) ? null : $this->message;
    }
}

#[Attribute(Attribute::TARGET_CLASS)]
final class Table
{
    public function __construct(public string $name) {}
}

#[Table('books')]
class Book
{
    public function __construct(
        #[Length(40)]
        public string $title,
        #[Satisfies(static function (string $isbn): bool { return preg_match('/^97[89]\d{10}$/', $isbn) === 1; }, 'a 13-digit ISBN')]
        #[Satisfies(static function (string $isbn): bool { return array_sum(str_split($isbn)) > 0; }, 'not all zeros')]
        public string $isbn,
    ) {}
}

final class Ebook extends Book {}

/** @return list<string> */
function validate(object $object): array
{
    $errors = [];
    foreach ((new ReflectionObject($object))->getProperties() as $property) {
        foreach ($property->getAttributes(Constraint::class, ReflectionAttribute::IS_INSTANCEOF) as $attribute) {
            $message = $attribute->newInstance()->check($property->getValue($object));
            if ($message !== null) {
                $errors[] = "{$property->getName()}: {$message}";
            }
        }
    }
    return $errors;
}

print_r(validate(new Book('The Left Hand of Darkness', '9780441478125')));
print_r(validate(new Ebook(str_repeat('Long title ', 5), '0441478123')));

// class attributes are not inherited; property attributes come with the declaring class
var_dump(count((new ReflectionClass(Ebook::class))->getAttributes(Table::class)));
var_dump((new ReflectionClass(Ebook::class))->getParentClass()->getAttributes(Table::class)[0]->newInstance()->name);
$length = (new ReflectionProperty(Book::class, 'title'))->getAttributes()[0];
var_dump($length->newInstance() === $length->newInstance());   // a new object on every call
Array
(
)
Array
(
    [0] => title: at most 40 characters
    [1] => isbn: a 13-digit ISBN
)
int(0)
string(5) "books"
bool(false)
  • IS_INSTANCEOF matches subclasses and interfaces. Without the flag, getAttributes(Constraint::class) returns only attributes whose class is exactly Constraint, which is abstract and never written, so the validator would find nothing. With it, Length and both Satisfies rules were found.

  • Closures as arguments (8.5) must be static and written with function: an arrow function, static fn included, failed with Constant expression contains invalid operations, the same error older versions give for any closure there. First-class callables such as trim(...) are allowed as well (also 8.5). Before 8.5, the usual workaround was a class name or a method name string in the attribute.

  • Attributes on an inherited property come with it, because getProperties() on the Ebook returns the properties declared in Book, rules included, so the e-book with a long title and a 10-digit ISBN failed both checks. A class attribute is not inherited: Ebook has no Table attribute of its own, and a reader that wants inheritance must walk getParentClass() itself.

  • newInstance() creates a new object on every call, running the constructor each time. A validator called for every request should build its rule objects once per class and keep them, as the framework attribute readers do.

How frameworks use attributes

Attributes have replaced docblock annotations (@Route, @ORM\Column) across the ecosystem:

  • PHPUnit 10+ configures tests with attributes: #[Test], #[DataProvider('…')], #[CoversClass(…)], #[Group('…')]; PHPUnit 12 removed support for the old docblock annotations (see Testing with PHPUnit and Pest).

  • Laravel 13 offers attributes such as #[Middleware] and #[Authorize] on controllers, #[Tries], #[Backoff] and #[Timeout] on queued jobs, Eloquent model attributes (#[Table], #[Fillable], #[ObservedBy], #[Scope]) and contextual container attributes (#[Config], #[CurrentUser]); see Controllers and Queues and Jobs.

  • Symfony uses #[Route], #[AsCommand], #[Autowire] and validation constraints such as #[Assert\NotBlank]; Doctrine ORM maps entities with #[ORM\Entity] and #[ORM\Column].

Design advice for your own attributes: keep them declarative (data, no behaviour), give them typed constructor parameters, read them in one place, and cache the result.

Where older books and tutorials are outdated

Attributes did not exist before PHP 8.0. Books written for PHP 7 (and 2010-era frameworks such as Symfony 1, Doctrine 1 and Zend Framework) use docblock annotations parsed by libraries like doctrine/annotations, which is now abandoned. Every major framework and tool has moved to native attributes; PHPUnit 12 no longer reads @test or @dataProvider annotations at all.