<? phpukraine СПІВБЕСІДИ
Пошук по платформі
CORE PHP · MIDDLE

Що таке атрибути в PHP 8 і як їх читають через Reflection?

Атрибути (PHP 8.0) - це структуровані метадані `#[Attr(...)]`, які компілятор зберігає разом із класом, методом, властивістю чи параметром і які читають через `getAttributes()` у Reflection. Сам атрибут нічого не робить: його треба знайти рефлексією і перетворити на обʼєкт через `newInstance()`, тому продакшн-код робить це один раз під час прогріву кешу, а не на кожен запит.

Чим `#[Route]` у Symfony принципово відрізняється від старої анотації `@Route` у docblock?
Ви написали власний атрибут із `TARGET_CLASS`, а хтось повісив його на метод. Коли PHP про це скаже?
Скільки коштує прочитати атрибути з двохсот класів на кожному запиті і що з цим роблять фреймворки?
Чому `getAttributes()` відпрацював без помилки, хоча класу атрибута в проєкті взагалі немає?
attributes reflection php8 metadata caching routing validation

Атрибути принесли в PHP 8.0 структуровані метадані: компілятор зберігає їх поруч із класом, методом, властивістю, константою класу чи параметром. Синтаксис #[Route('/orders')] навмисно виріс із коментаря #, щоб старі версії PHP просто ігнорували рядок. На відміну від докблоків із @Route, розбирає їх парсер мови, а не регулярка бібліотеки: імена резолвляться за звичайними правилами namespace і use, аргументи читаються як справжні аргументи виклику, з іменованими включно, а помилку в дужках видно ще на компіляції. За строгість доводиться платити константними виразами в аргументах: літерали, константи, кейси enum, new в ініціалізаторах (з PHP 8.1). Виклику функції чи змінної там не буде ніколи.

Власний атрибут - звичайний клас, позначений #[Attribute]. Прапорці в дужках описують дозволені місця: TARGET_CLASS, TARGET_METHOD, TARGET_PROPERTY, TARGET_CLASS_CONSTANT, TARGET_FUNCTION, TARGET_PARAMETER, зведений TARGET_ALL і окремий IS_REPEATABLE для випадків, коли атрибут вішають кілька разів (у прикладі це два маршрути на один метод). PHP 8.5 додав до набору TARGET_CONSTANT для глобальних констант. Тонкість, яку люблять питати: жоден із цих прапорців не перевіряється під час парсингу. Файл із #[Route] на властивості завантажиться без єдиного попередження, а Error про заборонений target прилетить лише тоді, коли хтось спробує матеріалізувати атрибут.

Читають атрибути через Reflection. getAttributes() є в ReflectionClass, ReflectionMethod, ReflectionProperty, ReflectionParameter, ReflectionClassConstant і ReflectionFunction, і повертає він масив ReflectionAttribute. Це ліниві описи: getName() дає імʼя, getArguments() - сирі аргументи, клас атрибута на цьому етапі навіть не автозавантажується. Матеріалізація починається з newInstance(): PHP підвантажує клас, звіряє target і повторюваність, викликає конструктор і віддає готовий обʼєкт. Фільтр getAttributes(Route::class, ReflectionAttribute::IS_INSTANCEOF) зручний тим, що ловить і нащадків базового атрибута, але й він змушує завантажити клас, тож «нульова ціна» опису тут уже втрачена.

Дорого не створити один ReflectionClass, дорого обійти сотні. Щоб зібрати маршрути з усіх контролерів, доводиться просканувати файли, завантажити кожен клас, перебрати методи й розпакувати атрибути, а OPcache цього не рятує: він тримає байткод, а не результат вашого обходу. Робочий патерн тут один - заплатити за рефлексію в build-кроці й скласти результат у плаский PHP-масив. Так живуть php artisan route:cache і event:cache у Laravel, дамп контейнера й компільовані маршрути в Symfony, кеш метаданих AttributeDriver у Doctrine ORM (він замінив анотації в 2.9 і залишився єдиним варіантом у 3.0). У прикладі коду ця ідея згорнута до двох рядків із require, але суть та сама. Про зворотний бік кешу теж не забувайте: змінили атрибут - переконайтеся, що деплой перебудовує кеш, інакше прод працюватиме по старих метаданих.

Типових ролей у атрибутів три. Роутинг: #[Route] у Symfony чи #[AsCommand] для консольних команд тримають опис поруч із кодом, який його виконує. Валідація і мапінг: #[Assert\NotBlank], #[Assert\Length(min: 3)], #[ORM\Column(type: 'string')] описують правило прямо на властивості, і той самий обʼєкт-правило потім використовує валідатор. У Laravel-екосистемі це #[ObservedBy], #[Scope], контекстні #[Config] і #[CurrentUser] у параметрах конструктора; Filament окремого шару атрибутів майже не тримає, бо його сторінки й ресурси - це Livewire-компоненти, тож там працюють #[Url], #[On], #[Computed], #[Locked] із Livewire 3, які читаються при кожному завантаженні компонента. Окремо стоять службові атрибути самої мови: #[\SensitiveParameter] ховає значення в стектрейсах (8.2), #[\Override] ловить помилку в імені перевизначеного методу (8.3), #[\Deprecated] дає нативне попередження про застарілу функцію чи метод (8.4).

Межа застосування проходить там, де метадані перестають бути властивістю коду. Маршрут поруч із дією і тип колонки поруч із властивістю читаються краще за окремий YAML. А от ліміти, які змінює менеджер, налаштування на клієнта чи мапінг для класу з vendor/ в атрибут не покладеш: він вмурований у файл і міняється тільки деплоєм. Ще одна пастка - накопичення: властивість, обвішана атрибутами ORM, валідатора й серіалізатора, тягне в доменний клас знання про базу, HTTP і форму водночас. Якщо на це схоже, простіше розділити сутність і DTO, ніж далі нарощувати декларації над одним полем.

declare(strict_types=1);

#[Attribute(Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE)]
final class Route
{
    /** @param list<string> $methods */
    public function __construct(
        public string $path,
        public array $methods = ['GET'],
        public ?string $name = null,
    ) {}
}

final class OrderController
{
    #[Route('/orders', name: 'orders.index')]
    #[Route('/orders/archive', name: 'orders.archive')]  // IS_REPEATABLE дозволяє другий
    public function index(): string { return 'list'; }

    #[Route('/orders', methods: ['POST'], name: 'orders.store')]
    public function store(): string { return 'created'; }
}

/** @return list<array{path: string, methods: list<string>, action: string}> */
function collectRoutes(string $class): array
{
    $routes = [];
    foreach ((new ReflectionClass($class))->getMethods(ReflectionMethod::IS_PUBLIC) as $method) {
        // фільтр з IS_INSTANCEOF ловить і нащадків Route, але вже вимагає завантажити клас
        foreach ($method->getAttributes(Route::class, ReflectionAttribute::IS_INSTANCEOF) as $attribute) {
            $route = $attribute->newInstance();   // саме тут перевіряється target і конструктор
            $routes[] = [
                'path' => $route->path,
                'methods' => $route->methods,
                'action' => $method->getName(),
            ];
        }
    }

    return $routes;
}

// рефлексію платимо один раз під час прогріву, у рантаймі лишається require
$cacheFile = __DIR__ . '/routes.cache.php';
$routes = is_file($cacheFile) ? require $cacheFile : collectRoutes(OrderController::class);
Що атрибут - це декларація, а не поведінка: без коду, який його прочитає рефлексією, `#[Route]` не робить нічого.
Що `#[Attribute(Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE)]` описує, куди атрибут можна вішати, але перевірка цільового місця й арности відкладена до `newInstance()`, а не робиться при парсингу файла.
Що `getAttributes()` повертає `ReflectionAttribute`, тобто ліниві описи (`getName()`, `getArguments()`), і клас атрибута не автозавантажується, поки ви не покличете `newInstance()` або не передасте фільтр із `ReflectionAttribute::IS_INSTANCEOF`.
Що аргументи атрибута - константні вирази: літерали, константи, enum-кейси, `new` в ініціалізаторах (з PHP 8.1), але не виклик функції чи змінна.
Що сканування рефлексією не кешується OPcache-ом і його виносять у build-крок: `php artisan route:cache`, дамп контейнера Symfony, метадані-кеш Doctrine.
Вважати, що PHP сам «застосує» атрибут: без свого сканера чи фреймворку, який його читає, `#[Assert\NotBlank]` на властивості - просто коментар із дужками.
Розраховувати, що неправильний target зловиться під час деплою: файл із `#[Route]` на властивості парситься мовчки, `Error` вилетить тільки на `newInstance()`, тобто в рантаймі.
Писати в аргументі атрибута `#[Cache(ttl: 60 * config('cache.ttl'))]` і отримувати помилку компіляції: дозволені лише константні вирази.
Вішати один і той самий атрибут двічі без `Attribute::IS_REPEATABLE` і не розуміти, звідки «Attribute … must not be repeated».
Обходити рефлексією всі класи проєкту на кожен HTTP-запит замість того, щоб зібрати мапу один раз у кеш.
Міняти сигнатуру класу-атрибута після `route:cache` чи дампа контейнера і дивуватися, що прод працює по старих метаданих.
ПОРАДА

Формула, яку варто сказати вголос: «атрибут - це дані в AST, а не код; поведінку дає той, хто його прочитає через `getAttributes()` і матеріалізує `newInstance()`». Одразу додайте, що target перевіряється саме на `newInstance()`, і що ціну рефлексії платять один раз під час прогріву кешу - це показує, що ви бачили і бібліотечний, і продакшн-бік.

оновлено 6 вересня 2026 · ліцензія CC-BY-SA-4.0 Знайшли неточність? Напишіть →
ПЕРЕВІРТЕ СЕБЕ

Компілятор перевіряє тільки синтаксис і константність аргументів. `getAttributes()` повертає ліниві `ReflectionAttribute` без завантаження класу атрибута; перевірка дозволених targets, повторюваності й виклик конструктора відбуваються на `newInstance()`. OPcache кешує байткод, а не результат обходу рефлексією.