Чистое API для чтения PHP-атрибутов: как упростить работу с Reflection

Большинство разработчиков даже не подозревают, сколько строк кода они пишут каждый раз, когда читают атрибуты. Reflection API заставляет повторять одни и те же конструкции в каждом проекте. Но есть способ сократить этот код на 80% без потери гибкости.

PHP 8.0 представил атрибуты — отличный способ добавлять структурированные метаданные прямо в код. Концепция элегантна, но вот Reflection API, с помощью которого эти атрибуты нужно читать, удивительно многословен и излишне детализирован. Возникает разрыв между простотой идеи и сложностью её реализации.

Spatie выпустили пакет spatie/php-attribute-reader, который устраняет этот разрыв, предлагая чистое и интуитивное API. Давайте посмотрим, что он умеет.

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

Чтение одного PHP атрибута

Представьте, что у контроллера есть атрибут Route, и вам нужно получить его экземпляр. С нативным PHP это выглядит так:

$reflection = new ReflectionClass(MyController::class);
$attributes = $reflection->getAttributes(Route::class, ReflectionAttribute::IS_INSTANCEOF);

$route = null;
if (count($attributes) > 0) {
$route = $attributes[0]->newInstance();
}

Пять строк только для одного PHP атрибута. С пакетом от Spatie всё становится проще:

use Spatie\Attributes\Attributes;

$route = Attributes::get(MyController::class, Route::class);

Одна строка. Если атрибута нет — вернётся null. Никаких лишних проверок.

Для разных целей (методы, свойства, константы) есть отдельные методы:

Attributes::onMethod(MyController::class, 'index', Route::class);
Attributes::onProperty(User::class, 'email', Column::class);
Attributes::onConstant(Status::class, 'ACTIVE', Label::class);
Attributes::onParameter(MyController::class, 'show', 'id', FromRoute::class);

Проверка наличия PHP атрибута

Если нужно просто проверить, есть ли атрибут, без получения его экземпляра, используйте has():

if (Attributes::has(MyController::class, Route::class)) {
// атрибут существует
}

Это работает для всех типов целей — классов, методов, свойств и т.д.

Повторяющиеся PHP атрибуты

Некоторые PHP атрибуты могут быть повторяющимися (IS_REPEATABLE). Например, middleware в контроллере:

#[Route('/users')]
#[Middleware('auth')]
#[Middleware('verified')]
public function index() {}

Для получения всех экземпляров используйте getAllOnMethod:

$middlewares = Attributes::getAllOnMethod(
UserController::class,
'index',
Middleware::class
);

foreach ($middlewares as $middleware) {
echo $middleware->name; // 'auth', 'verified'
}

Аналогичные методы есть для свойств, констант, параметров и функций.

Поиск всех PHP атрибутов

Но нативное Reflection API становится по-настоящему громоздким, когда нужно найти все вхождения определённого атрибута в классе. Представьте класс формы, где несколько свойств используют PHP атрибут Validate для правил валидации:

#[Attribute]
class Validate
{
public function __construct(public string $rule = 'required') {}
}

#[Validate('exists:forms')]
class ContactForm
{
#[Validate('string|max:255')]
public string $name;

#[Validate('email')]
public string $email;

public function submit(#[Validate('array')] array $data) {}
}

Теперь нам нужно найти все места в классе ContactForm, где используется атрибут Validate. Вот упрощённый пример того, что пришлось бы писать на нативном PHP:

// Лишь малая часть того, что вам реально понадобится:
$results = [];
$class = new ReflectionClass(ContactForm::class);

// Проверяем свойства...
foreach ($class->getProperties() as $property) {
foreach ($property->getAttributes(Validate::class) as $attr) {
// Здесь мы теряем контекст - к какому свойству относится атрибут
$results[] = $attr->newInstance();
}
}

// ...и так нужно повторить для методов, параметров и констант.
// Огромное количество повторяющегося кода, в котором легко ошибиться.

Сравните с тем, как ту же задачу решает пакет:

use Spatie\Attributes\Attributes;

$results = Attributes::find(ContactForm::class, Validate::class);

Вместо того чтобы писать парсер для свойств, потом для методов, а потом для параметров — вы просто говорите «найди всё». Это и есть чистое API.

Работа с результатами поиска

Каждый результат — это объект AttributeTarget с тремя полезными свойствами:

use Spatie\Attributes\AttributeTarget;

foreach ($results as $result) {
$result->attribute; // Готовый экземпляр атрибута
$result->target; // Объект Reflection (свойство, метод и т.д.)
$result->name; // Например, 'email' или 'submit.data'
}

Для параметров имя формируется как метод.параметр. В примере с классом ContactForm и атрибутом Validate результаты будут выглядеть так:

nametarget typeattribute->rule
ContactFormReflectionClassexists:forms
nameReflectionPropertystring|max:255
emailReflectionPropertyemail
submit.dataReflectionParameterarray

Получение атрибутов в виде массива

Метод toArray() преобразует все свойства PHP атрибута в ассоциативный массив. Это удобно, когда структура атрибута неизвестна заранее:

foreach ($results as $result) {
$config = $result->toArray();
// ['rule' => 'exists:forms'] для класса
// ['rule' => 'email'] для свойства email
}

Доступ к reflection-объекту

Свойство target даёт полный доступ к нижележащему reflection-объекту. Можно получить дополнительную информацию о цели:

foreach (Attributes::find(ContactForm::class, Validate::class) as $result) {
if ($result->target instanceof ReflectionProperty) {
$type = $result->target->getType(); // тип свойства
}
}

Один метод find заменяет десятки строк кода по всему проекту.

Работа с иерархией PHP атрибутов

Все методы пакета по умолчанию используют ReflectionAttribute::IS_INSTANCEOF. Это значит, что если вы ищете родительский класс атрибута, будут найдены и его наследники.

#[Attribute]
class CacheStrategy
{
public function __construct(public int $ttl = 3600) {}
}

#[Attribute]
class AggressiveCache extends CacheStrategy
{
public function __construct()
{
parent::__construct(ttl: 86400);
}
}

#[AggressiveCache]
class ProductCatalog {}

Теперь при поиске CacheStrategy будет найден AggressiveCache:

$cache = Attributes::get(ProductCatalog::class, CacheStrategy::class);

$cache instanceof AggressiveCache; // true
$cache->ttl; // 86400

Это работает для всех методов: get, has, find, onMethod, onProperty и других.

В заключение

Пакет уже используется в нескольких проектах, включая laravel-responsecache, laravel-event-sourcing и laravel-markdown. В каждом случае он помог убрать десятки строк шаблонного reflection-кода, сделав кодовую базу чище, удобнее для тестирования и понимания.

Полная документация доступна на сайте Spatie, а исходный код — на GitHub. Это один из многих пакетов, созданных командой.

Комментарии


Дополнительные материалы

Предыдущая Статья

Руководство по стилю объектного проектирования для PHP 8.5