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