MidТеория5 min

Основы атрибутов

Синтаксис #[Attribute], цели применения, чтение через Reflection

Основы атрибутов — PHP 8.0+

Что такое атрибуты

Атрибуты -- это структурированные метаданные, которые можно прикрепить к классам, методам, свойствам, параметрам, константам и функциям. Они пришли на замену аннотациям в doc-блоках (таким как @Route, @ORM\Column из Doctrine).

До PHP 8.0 -- метаданные через комментарии:

<?php

/**
 * @Route("/api/users", methods={"GET"})
 * @IsGranted("ROLE_ADMIN")
 */
class UserController
{
    /**
     * @var UserService
     * @Inject
     */
    private $service;
}

С PHP 8.0 -- нативные атрибуты:

<?php
declare(strict_types=1);

#[Route('/api/users', methods: ['GET'])]
#[IsGranted('ROLE_ADMIN')]
class UserController
{
    #[Inject]
    private UserService $service;
}

Ключевые отличия от doc-блоков:

  • Нативный синтаксис -- часть языка, не комментарии
  • Проверяются при компиляции -- ошибка в имени атрибута вызовет исключение при инстанцировании
  • Типизированные аргументы -- конструктор атрибута проверяет типы
  • Автодополнение в IDE -- полная поддержка PhpStorm, VS Code

Синтаксис

Атрибуты записываются в #[...] перед целевой конструкцией:

<?php
declare(strict_types=1);

// Атрибут на классе
#[Route('/api/users', methods: ['GET'])]
class UserController
{
    // Атрибут на свойстве
    #[Inject]
    private UserService $service;

    // Атрибут на константе класса
    #[Deprecated('Use VERSION_2 instead')]
    public const VERSION_1 = '1.0';

    // Несколько атрибутов на методе
    #[Route('/api/users/{id}')]
    #[Middleware('auth')]
    public function show(
        // Атрибут на параметре
        #[FromRoute] int $id
    ): Response {
        return new Response($this->service->find($id));
    }
}

// Атрибут на функции
#[Pure]
function calculateSum(int $a, int $b): int
{
    return $a + $b;
}

Несколько атрибутов можно записать в одних скобках через запятую:

<?php
declare(strict_types=1);

// Два способа -- эквивалентны
#[Route('/users')]
#[Middleware('auth')]
public function index(): Response {}

// Компактный вариант
#[Route('/users'), Middleware('auth')]
public function index(): Response {}

Запомни: Атрибуты -- это просто классы PHP. #[Route('/path')] эквивалентно new Route('/path'), только вызов newInstance() происходит через Reflection.

Цели атрибутов (targets)

При создании атрибута можно ограничить, к чему он применяется. Это делается через флаги Attribute::TARGET_*:

<?php
declare(strict_types=1);

use Attribute;

// Только для методов и функций
#[Attribute(Attribute::TARGET_METHOD | Attribute::TARGET_FUNCTION)]
final class Route
{
    public function __construct(
        public readonly string $path,
        public readonly array $methods = ['GET'],
    ) {}
}

// Только для свойств
#[Attribute(Attribute::TARGET_PROPERTY)]
final class Column
{
    public function __construct(
        public readonly string $name,
        public readonly string $type = 'string',
        public readonly bool $nullable = false,
    ) {}
}

// Для всего (по умолчанию)
#[Attribute]
final class Description
{
    public function __construct(
        public readonly string $text,
    ) {}
}

Полный список целей:

Флаг Назначение
Attribute::TARGET_CLASS Классы, интерфейсы, трейты, enums
Attribute::TARGET_FUNCTION Функции
Attribute::TARGET_METHOD Методы классов
Attribute::TARGET_PROPERTY Свойства классов
Attribute::TARGET_CLASS_CONSTANT Константы классов
Attribute::TARGET_PARAMETER Параметры функций/методов
Attribute::TARGET_ALL Все вышеперечисленное (по умолчанию)

Если атрибут применен не к той цели, при вызове newInstance() будет выброшен Error:

<?php
declare(strict_types=1);

#[Attribute(Attribute::TARGET_METHOD)]
final class OnlyForMethods {}

// Применяем к классу -- ошибки при объявлении НЕ будет
#[OnlyForMethods]
class Foo {}

// Ошибка возникнет только при чтении через Reflection
$ref = new ReflectionClass(Foo::class);
$attrs = $ref->getAttributes(OnlyForMethods::class);
$attrs[0]->newInstance();
// Error: Attribute "OnlyForMethods" cannot target class (is only for methods)

Создание пользовательских атрибутов

Атрибут -- это обычный PHP-класс с атрибутом #[Attribute]:

<?php
declare(strict_types=1);

use Attribute;

#[Attribute(Attribute::TARGET_METHOD | Attribute::TARGET_FUNCTION)]
final class Route
{
    public function __construct(
        public readonly string $path,
        public readonly array $methods = ['GET'],
        public readonly ?string $name = null,
        public readonly array $middleware = [],
    ) {}
}

#[Attribute(Attribute::TARGET_PROPERTY)]
final class Validate
{
    public function __construct(
        public readonly string $rule,
        public readonly ?string $message = null,
    ) {}
}

#[Attribute(Attribute::TARGET_CLASS)]
final class Entity
{
    public function __construct(
        public readonly string $table,
        public readonly ?string $repository = null,
    ) {}
}

Использование:

<?php
declare(strict_types=1);

#[Entity(table: 'users', repository: UserRepository::class)]
final class User
{
    #[Validate(rule: 'email', message: 'Invalid email')]
    public string $email;

    #[Validate(rule: 'min:8')]
    public string $password;
}

Чтение атрибутов через Reflection

Атрибуты сами по себе ничего не делают. Они хранятся в метаданных и читаются через Reflection API:

<?php
declare(strict_types=1);

// Чтение атрибутов класса
$ref = new ReflectionClass(User::class);
$attributes = $ref->getAttributes(Entity::class);

foreach ($attributes as $attr) {
    echo $attr->getName();       // 'Entity'
    echo $attr->getTarget();     // Attribute::TARGET_CLASS
    $args = $attr->getArguments(); // ['table' => 'users', ...]

    // Создание экземпляра атрибута
    $entity = $attr->newInstance();
    echo $entity->table;         // 'users'
    echo $entity->repository;    // 'UserRepository'
}

// Чтение атрибутов свойств
foreach ($ref->getProperties() as $prop) {
    $attrs = $prop->getAttributes(Validate::class);
    foreach ($attrs as $attr) {
        $validate = $attr->newInstance();
        echo "{$prop->getName()}: {$validate->rule}\n";
    }
}

// Чтение атрибутов метода
$method = new ReflectionMethod(UserController::class, 'show');
$routeAttrs = $method->getAttributes(Route::class);

if (count($routeAttrs) > 0) {
    $route = $routeAttrs[0]->newInstance();
    echo $route->path;     // '/api/users/{id}'
    echo $route->methods;  // ['GET']
}

// Чтение атрибутов параметров
foreach ($method->getParameters() as $param) {
    $attrs = $param->getAttributes();
    foreach ($attrs as $attr) {
        echo "{$param->getName()} has attribute: {$attr->getName()}\n";
    }
}

Метод getAttributes() принимает необязательные параметры:

<?php
declare(strict_types=1);

// Все атрибуты
$all = $ref->getAttributes();

// Только конкретный атрибут
$routes = $ref->getAttributes(Route::class);

// Атрибуты по наследованию (для интерфейсов)
$attrs = $ref->getAttributes(
    MyInterface::class,
    ReflectionAttribute::IS_INSTANCEOF
);
// Найдет атрибуты, которые implements MyInterface

Повторяемые атрибуты (IS_REPEATABLE)

По умолчанию атрибут можно применить к цели только один раз. Флаг IS_REPEATABLE снимает это ограничение:

<?php
declare(strict_types=1);

use Attribute;

#[Attribute(Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE)]
final class Middleware
{
    public function __construct(
        public readonly string $name,
        public readonly int $priority = 0,
    ) {}
}

class ApiController
{
    #[Middleware('auth')]
    #[Middleware('throttle', priority: 10)]
    #[Middleware('cors', priority: 20)]
    #[Middleware('log')]
    public function store(): Response
    {
        // ...
    }
}

// Чтение всех middleware
$method = new ReflectionMethod(ApiController::class, 'store');
$middlewares = $method->getAttributes(Middleware::class);

foreach ($middlewares as $attr) {
    $mw = $attr->newInstance();
    echo "{$mw->name} (priority: {$mw->priority})\n";
}
// auth (priority: 0)
// throttle (priority: 10)
// cors (priority: 20)
// log (priority: 0)

Ловушка: Без IS_REPEATABLE повторное использование атрибута вызовет Error при newInstance(). Не при объявлении, а при чтении!

Вложенные атрибуты

Аргументы атрибутов должны быть константными выражениями. Начиная с PHP 8.1, можно передавать new в аргументах:

<?php
declare(strict_types=1);

use Attribute;

#[Attribute]
final class Validator
{
    public function __construct(
        public readonly string $field,
        public readonly Rule $rule,
    ) {}
}

#[Attribute]
final class Rule
{
    public function __construct(
        public readonly string $type,
        public readonly int|string|null $value = null,
    ) {}
}

// PHP 8.1+: вложенные атрибуты через new
#[Validator(
    field: 'email',
    rule: new Rule(type: 'email')
)]
#[Validator(
    field: 'age',
    rule: new Rule(type: 'min', value: 18)
)]
class RegistrationForm {}

Допустимые типы аргументов:

  • Скалярные: string, int, float, bool
  • Массивы скаляров: ['GET', 'POST']
  • Константы классов и enums: Status::Active
  • Выражения new (PHP 8.1+)
  • null

Недопустимые аргументы:

  • Переменные: $path -- нельзя
  • Вызовы функций: strtolower('GET') -- нельзя
  • Замыкания: fn() => 'x' -- нельзя

Атрибуты в аргументах конструктора

Constructor property promotion работает вместе с атрибутами:

<?php
declare(strict_types=1);

use Attribute;

#[Attribute(Attribute::TARGET_PROPERTY)]
final class Column
{
    public function __construct(
        public readonly string $name,
    ) {}
}

#[Attribute(Attribute::TARGET_PARAMETER)]
final class Autowire
{
    public function __construct(
        public readonly ?string $service = null,
    ) {}
}

final class User
{
    public function __construct(
        #[Column('user_email')]
        public readonly string $email,

        #[Column('user_name')]
        public readonly string $name,
    ) {}
}

// Атрибут автоматически привязан к СВОЙСТВУ (через promotion)
$ref = new ReflectionClass(User::class);
$prop = $ref->getProperty('email');
$attrs = $prop->getAttributes(Column::class);
echo $attrs[0]->newInstance()->name;  // 'user_email'

// Атрибут также доступен на параметре конструктора
$ctor = $ref->getConstructor();
$param = $ctor->getParameters()[0];
$attrs = $param->getAttributes(Column::class);
echo $attrs[0]->newInstance()->name;  // 'user_email'

Важно: При constructor property promotion атрибут привязывается и к свойству, и к параметру. Если ваш атрибут нацелен на TARGET_PROPERTY, он корректно читается через ReflectionProperty. Если на TARGET_PARAMETER -- через ReflectionParameter.

Практический пример: мини-роутер

<?php
declare(strict_types=1);

use Attribute;

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

class UserController
{
    #[Route('/users', method: 'GET')]
    public function index(): string { return 'List users'; }

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

    #[Route('/users/{id}', method: 'GET')]
    #[Route('/users/{id}/profile', method: 'GET')]
    public function show(int $id): string { return "User {$id}"; }
}

// Сбор маршрутов через Reflection
function collectRoutes(string $controllerClass): array
{
    $routes = [];
    $ref = new ReflectionClass($controllerClass);

    foreach ($ref->getMethods(ReflectionMethod::IS_PUBLIC) as $method) {
        $attrs = $method->getAttributes(Route::class);
        foreach ($attrs as $attr) {
            $route = $attr->newInstance();
            $routes[] = [
                'path' => $route->path,
                'method' => $route->method,
                'handler' => [$controllerClass, $method->getName()],
            ];
        }
    }

    return $routes;
}

$routes = collectRoutes(UserController::class);
// [
//   ['path' => '/users', 'method' => 'GET', 'handler' => [...]],
//   ['path' => '/users', 'method' => 'POST', 'handler' => [...]],
//   ['path' => '/users/{id}', 'method' => 'GET', 'handler' => [...]],
//   ['path' => '/users/{id}/profile', 'method' => 'GET', 'handler' => [...]],
// ]

Проверь себя

5 из 10

Какие значения допустимы в аргументах атрибутов?

Когда возникнет ошибка, если атрибут применён не к той цели (например, `TARGET_METHOD` применен к классу)?

Что произойдёт, если класс атрибута НЕ существует?

Что нужно сделать, чтобы атрибут можно было применять несколько раз?

Какой синтаксис используется для атрибутов в PHP 8.0+?