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.
Source: Attribute syntax and Attributes overview
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 |
|---|---|
|
classes, interfaces, traits, enums |
|
functions and closures |
|
methods |
|
properties |
|
class constants and enum cases |
|
function and method parameters |
|
global constants declared with |
|
everywhere (the default) |
|
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).
Source: Declaring Attribute Classes and The Attribute attribute
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)
}
Source: Declaring Attribute Classes
The predefined attributes
| Attribute | Since | Effect |
|---|---|---|
|
8.0 |
marks a class as an attribute class |
|
8.1 |
silences the deprecation for an overriding internal method without a tentative return type (migration aid) |
|
8.2 |
allows dynamic properties on a class (they are deprecated otherwise) |
|
8.2 |
replaces a parameter’s value with a |
|
8.3 |
compile-time check that a method overrides a parent or interface method (properties in 8.5) |
|
8.4 |
makes calling a function or method, or reading a class constant, emit |
|
8.5 |
warns when a function’s return value is not used (see Functions) |
|
8.5 |
defers target errors of internal attributes on the same declaration to |
<?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.
Source: Attributes, Deprecated, SensitiveParameter and Override
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)
}
Source: DelayedTargetValidation
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"
Source: RFC: Attributes on Constants and ReflectionConstant
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_INSTANCEOFmatches subclasses and interfaces. Without the flag,getAttributes(Constraint::class)returns only attributes whose class is exactlyConstraint, which is abstract and never written, so the validator would find nothing. With it,Lengthand bothSatisfiesrules were found. -
Closures as arguments (8.5) must be
staticand written withfunction: an arrow function,static fnincluded, failed withConstant expression contains invalid operations, the same error older versions give for any closure there. First-class callables such astrim(...)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 theEbookreturns the properties declared inBook, rules included, so the e-book with a long title and a 10-digit ISBN failed both checks. A class attribute is not inherited:Ebookhas noTableattribute of its own, and a reader that wants inheritance must walkgetParentClass()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.
References
-
PHP Manual — Attributes: overview, syntax, reading with Reflection, declaring attribute classes
-
PHP Manual — Predefined Attributes: Attribute, Override, Deprecated, NoDiscard, SensitiveParameter, AllowDynamicProperties, ReturnTypeWillChange, DelayedTargetValidation
-
PHP RFC — Attributes v2, Attributes on Constants, DelayedTargetValidation attribute